Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

connect4_v4 source realm

package connect4\_v4 is a staked Connect 4 game. A creator posts an offer with a ugnot stake; anyone (or a named oppo...

Readme View source

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. Offer escrows the creator's stake with an expiry (1-60 min) and an optional reserved opponent. Accept must send exactly the same stake. Both use cur.Previous().IsUserCall() + unsafe.OriginSend().
  • Presence via the move clock. Each move has 90s of block time. Accept proves the acceptor is present, Reveal the 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 call ClaimTimeout, so bystanders can settle stuck games. A late Play panics rather than settling, keeping one settlement path.
  • First mover by two-sided commit-reveal. Offer takes commitment, the lowercase hex sha256(passphrase); Accept takes seedCommitment, the same for the acceptor's seed. The game then waits (Turn = 0, Play rejected) for the creator's Reveal(id, passphrase) within 90s of Accept, then the acceptor's RevealSeed(id, seed) within 90s of that. The first mover is the low bit of sha256(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: ClaimTimeout pays 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. Offer takes 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. ActiveJSON gives 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) can SetFee and WithdrawFees (collected fees only, never stakes); it is whoever deploys the realm (init with an IsUserCall() previous), so the deploying multisig owns it with no address baked into the code. Ownership moves in two steps, TransferOwnership(newOwner) then AcceptOwnership() 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 Offer and TransferOwnership refuse it (address.gno, the check of p/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) may Play, Reveal, RevealSeed, ClaimTimeout and Cancel only. 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 otherwise Resign every 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 Offer and Accept panics 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 active tree 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 through ActiveJSON.

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 Accept block 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, Reveal and RevealSeed, 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.

Overview

package connect4_v4 is a staked Connect 4 game. A creator posts an offer with a ugnot stake; anyone (or a named opponent) accepts with the same stake; the winner takes the pot minus a flat house fee.

The first mover is picked by a two-sided commit-reveal. The creator offers with sha256(passphrase), the acceptor accepts with sha256(seed). Then the creator reveals the passphrase (Reveal) and the acceptor the seed (RevealSeed), each within 90s, and the first mover is drawn from both. When a player commits, the other side's secret is still hidden, so neither can grind for an outcome, and once its secret is revealed nobody can change it; the only way out is to not reveal, which forfeits. Watching a pending Accept tells the creator nothing either, so it cannot cancel unfavourable games. Each address may use a commitment once: a reused secret is already public.

Calls signed by an account session key (Quick play) may only Play, Reveal, RevealSeed, ClaimTimeout and Cancel: a stolen session key must not resign games, move stakes or act as the owner.

Every move has a 90s clock measured in block time. Nothing on-chain fires by itself, so an expired clock is settled by anyone calling ClaimTimeout: whoever did not reveal in time forfeits, a game whose first mover never moved is void and both stakes are refunded, otherwise the player who ran out of time forfeits.

Constants 1

Functions 16

func Accept

crossing Action
1func Accept(cur realm, id int, seedCommitment string)
source

Accept joins an open offer by sending exactly its stake. seedCommitment is the lowercase hex sha256 of a secret seed, revealed after the creator's passphrase. The creator then has 90s to Reveal.

func AcceptOwnership

crossing Action
1func AcceptOwnership(cur realm)
source

AcceptOwnership completes a TransferOwnership; only the named address may call it.

func ActiveJSON

Action
1func ActiveJSON(offset, limit int) string
source

ActiveJSON returns {"now":<unix>,"fee":<ugnot>,"games":[...]}: the house fee for new offers, then Open and Playing games in id order, skipping offset and returning at most limit (clamped to 1-100).

func Cancel

crossing Action
1func Cancel(cur realm, id int)
source

Cancel withdraws an open offer and refunds the creator. The creator may cancel at any time; anyone may once the offer has expired.

func ClaimTimeout

crossing Action
1func ClaimTimeout(cur realm, id int)
source

ClaimTimeout settles a game whose move clock ran out. Anyone may call it.

func GameJSON

Action
1func GameJSON(id int) string
source

GameJSON returns {"now":<unix>,"game":<game>|null}. It is a read-only accessor for clients querying over vm/qeval; now is the block time.

func LeadersJSON

Action
1func LeadersJSON() string
source

LeadersJSON returns {"wins":[...],"gnot":[...]}: the top-10 boards (most games won, most ugnot won) kept at settlement, highest first. Each entry is {"addr":<address>,"score":<int>}.

func Offer

crossing Action
1func Offer(cur realm, opponent address, validFor int, commitment string, maxFee int64) int
source

Offer posts a game open for validFor minutes. The stake is the ugnot sent with the call. A non-empty opponent makes the offer private. commitment is the lowercase hex sha256 of a secret passphrase, revealed after Accept.

func Play

crossing Action
1func Play(cur realm, id, column, move int)
source

Play drops a piece in column (1-7). move is the game's move count the player saw (Moves before this piece): a resent or delayed move that the game has moved past is refused instead of being played on a later turn.

func Render

1func Render(path string) string
source

Render serves "" (lobby and leaderboards), "new" (offer form) and "game/<id>" (one game with every action its state allows). Everything written comes from realm state or from query values that have been validated and re-formatted; raw path input is never echoed.

func Resign

crossing Action
1func Resign(cur realm, id int)
source

Resign concedes a game in progress.

func Reveal

crossing Action
1func Reveal(cur realm, id int, passphrase string)
source

Reveal discloses the creator's passphrase. It must come within 90s of Accept; the acceptor then has 90s to RevealSeed.

func RevealSeed

crossing Action
1func RevealSeed(cur realm, id int, seed string)
source

RevealSeed discloses the acceptor's seed, which with the creator's passphrase picks the first mover. It must come within 90s of Reveal.

func SetFee

crossing Action
1func SetFee(cur realm, amount int64)
source

SetFee changes the flat house fee, in ugnot, for new offers. Games already offered keep the fee they were created with.

func TransferOwnership

crossing Action
1func TransferOwnership(cur realm, newOwner address)
source

TransferOwnership names the next owner, who becomes owner by calling AcceptOwnership. Two steps, so a mistyped or non-canonical address can never strand ownership (and the fees): only an address that signs can accept, and it is recorded as it signs.

Types 3

type Game

struct
 1type Game struct {
 2	ID        int
 3	Creator   address
 4	Opponent  address // optional; if set, only this address may accept
 5	Acceptor  address
 6	Stake     int64 // ugnot, per player
 7	Fee       int64 // ugnot house fee, fixed at offer time
 8	ExpiresAt time.Time
 9	// Commitment is the lowercase hex sha256 of the creator's passphrase;
10	// SeedCommitment that of the acceptor's seed, given with Accept.
11	Commitment     string
12	SeedCommitment string
13	// Passphrase is set by Reveal; the draw waits for RevealSeed.
14	Passphrase string
15	Revealed   bool
16
17	Board    board
18	Turn     byte // 0 = awaiting the reveals, 1 = creator, 2 = acceptor
19	Moves    int
20	LastCol  int // column and row of the last piece dropped, valid if Moves > 0
21	LastRow  int
22	LastMove time.Time
23	Status   Status
24	Winner   address
25}
source

type Stats

struct
1type Stats struct {
2	Games, Wins, Draws int
3	GnotWon            int64 // total net ugnot won: the opponent's stake minus the fee
4}
source

type Status

ident
1type Status int
source

Methods on Status

func String

method on Status
1func (s Status) String() string
source

Dependencies

Source Files 12