README.md
connect4 - staked Connect 4
Context
We want a real-money Connect 4 on gno.land: equal ugnot stakes, winner takes the pot minus a small house fee, fast turns, a lobby of offers open for the next N minutes, and a guarantee both players are present once a game starts.
Two platform facts shape the design:
time.Now()is the block timestamp. It is deterministic and only advances per block; nothing executes on its own, so deadlines are enforced lazily by whichever transaction checks them.- Presence cannot be observed on-chain. The only proof a player is there is a transaction they signed.
Decision
- Lobby of offers.
Offerescrows the creator's stake with an expiry (1-60 min) and an optional reserved opponent.Acceptmust send exactly the same stake. Both usecur.Previous().IsUserCall()+unsafe.OriginSend(). - Presence via the move clock. Each move has 90s of block time.
Acceptproves the acceptor is present,Revealthe creator, and the first move the first mover. If the clock runs out with zero moves the game is void and both stakes are refunded (the absent player may be the creator who posted long ago); after at least one move, the player on turn forfeits. Anyone can callClaimTimeout, so bystanders can settle stuck games. A latePlaypanics rather than settling, keeping one settlement path. - First mover by two-sided commit-reveal.
Offertakescommitment, the lowercase hexsha256(passphrase);AccepttakesseedCommitment, the same for the acceptor's seed. The game then waits (Turn = 0,Playrejected) for the creator'sReveal(id, passphrase)within 90s of Accept, then the acceptor'sRevealSeed(id, seed)within 90s of that. The first mover is the low bit ofsha256(passphrase|seed|id|creator|acceptor). When a player commits, the other side's secret is still hidden, so neither can grind for an outcome; once revealed, a secret can't change. The only way out is not revealing, which forfeits:ClaimTimeoutpays the other player as a win. A pending Accept shows only a hash, so a creator watching the mempool learns nothing it could cancel on. - Commitments are one-use per address. A revealed secret is public, so reusing it would let the other side compute the draw. Reuse is refused per address (creator or acceptor), never globally, so nobody can block another player's offer by posting its commitment first.
- Fee is a flat 0.1 GNOT (100,000 ugnot) per decisive game (one with a
winner), adjustable by the owner up to 0.5 GNOT (half the minimum stake)
and snapshotted per game at
Offer, so a live game's terms never change.Offertakes the highest fee the creator accepts (maxFee) and refuses a higher one, so the owner can't raise it while an offer is in flight; the cap bounds it for everyone.ActiveJSONgives the current fee, and the gnoweb offer link passes it. Draws and void games pay no fee. The lobby shows each offer's own fee. The owner (p/nt/ownable/v0) canSetFeeandWithdrawFees(collected fees only, never stakes); it is whoever deploys the realm (initwith anIsUserCall()previous), so the deploying multisig owns it with no address baked into the code. Ownership moves in two steps,TransferOwnership(newOwner)thenAcceptOwnership()by that address, so a mistyped spelling can never strand ownership and fees. - Address arguments must be canonical. The chain accepts four texts for
one address (bech32 or bech32m, lower or upper case), but a caller's
address is always lower-case bech32. An opponent or a new owner in another
spelling could never match its caller, so
OfferandTransferOwnershiprefuse it (address.gno, the check ofp/samcrew/launchpad/meta/v1, copied so the realm doesn't depend on that package). - Stale moves.
Play(id, column, move)takes the move count the player saw and refuses any other. A client that re-sends a move whose outcome it could not see (a dropped response) cannot have it land on a later turn. - Session keys. Calls signed by a tm2 account session key (gno #5307;
runtime.GetSessionInfo) mayPlay,Reveal,RevealSeed,ClaimTimeoutandCancelonly. Session allow-paths are per realm, not per function, and a session's spend limit counts gas and sent coins but not what a call forfeits: a stolen session key could otherwiseResignevery live game.Offer/Accept(stakes) and the owner functions need the account's own key too. - Cancel. The creator may cancel an open offer at any time; anyone may once it has expired, so stale offers can be cleared. The refund always goes to the creator.
- Non-payable calls reject coins. Every function other than
OfferandAcceptpanics if coins are sent, so nothing gets stuck in the realm. - Payouts are pushed with a RealmSend banker inside the settling tx, after state is updated. Native coin sends run no callee code.
- Leaderboards (wins, net ugnot won: the opponent's stake minus the
fee) update only on Won/Draw, so void
or cancelled games cannot farm stats. The two top-10 boards are kept at
settlement (
bump), so Render never walks every player's stats. - An
activetree holds only Open/Playing games so the lobby render cost does not grow with history, and the lobby lists at most 50 of them; clients page throughActiveJSON.
Alternatives considered
-
First mover from a hash computed in
Accept(block height, time, ids). Rejected: a tx can carry several messages and is atomic, so an acceptor can send[Accept, Play]and let the whole tx revert whenever the pick goes against them, then retry next block. -
Creator-only commit-reveal, with the
Acceptblock height mixed in (the first hardened version). Rejected in review: a short private offer can only be accepted at about 12-18 heights, so a creator can grind one passphrase that moves first at all of them (about 2^18 tries). -
An acceptor seed sent in clear with
Accept. Rejected: the creator sees it in the mempool and could cancel, or accept with another account, before the Accept lands whenever the draw goes against it. -
The creator picking the first mover in the offer, or a two-game match with each side moving first once: no randomness at all, but a different game.
-
Matchmaking queue by stake tier: faster pairing, but no browsable lobby and it pairs people with idle players.
-
Explicit "ready" handshake after accept: an extra tx per game that the first move already provides.
-
Forfeit on first-move timeout: punishes an acceptor's opponent who simply posted an offer and left before it was taken; void is fairer.
-
Chess-style time banks / per-game timeouts: more state and UI for little gain at this stage.
Consequences
- Clocks are only as precise as block production: a move can land a few seconds past 90s if no block was produced in between. Symmetric for both players.
- A weak secret can be brute-forced offline from its public commitment,
letting the other side steer the draw. This cannot be enforced on-chain;
the pages ask for
openssl rand -hex 32, and Memba generates 32 random bytes for both. - Each game needs two extra txs before the first move,
RevealandRevealSeed, each within 90s; both players must stay online until then. - Self-play with an alt account inflates wins but costs the fee.
- A stolen session key can still play bad moves, one per turn, in the owner's live games. That is inherent to signing moves without the wallet; the client keeps sessions short and bounded.
Security review (before mainnet)
An audit against docs/resources/gno-ai-contract-review.md, the payment
guidance in effective-gno.md and misc/audit-pattern-harness, then two
reviews of this repository's PR, led to the two-sided draw, one-use
commitments, the stale-move check, session-key refusals, the fee cap,
two-step ownership, settlement-time leaderboards, the capped lobby and the
deployer-owner above. Confirmed sound:
the IsUserCall() + OriginSend() payment pair, no exported pointers or
callbacks, Render writing only validated addresses and formatted numbers,
fee < stake per game, exact refunds, and Play (until the deadline) and
ClaimTimeout (after it) never overlapping. The harness's current_guard
hits are the cur.IsCurrent() guard that AGENTS.md says not to write in
crossing functions.
Read accessors
GameJSON(id) (with seedCommitment and revealed), ActiveJSON(offset, limit) and LeadersJSON() (the two top-10 boards) return hand-built JSON for
clients (Memba's Arcade) that read over vm/qeval instead of scraping
Render. Each response carries now, the block time, so clients count the
90s clock in chain time. The board is a 42-char column-major string. Strings
go through strconv.Quote although every stored string is validated on
write. The accessors never call statsOf, which writes on a miss.