A deadline that actions push forward, and that still ends: New, Bump,
BumpBy, BumpShare, Expired, Remaining, Deadline, HardEnd, Final.
1import"gno.land/p/moul/x/games/clock/v0"23c,_:=clock.New(height,2000,300,100000)// now, window, floor, life4c.Deadline()// height+20005c.BumpShare(h,20)// a write buys a fifth of the gap to the hard end6c.Expired(h)// the game is over, and cannot be reopened7c.Final()// the deadline has hit the hard end: holding now beats acting
Every last-action-wins game has this clock, and written the obvious way it fails
in one of two opposite directions.
With a constant extension it never ends. The pot grows faster than the price
of the next action, so the pot-to-price ratio rises, there is always a rational
next action, and the second-to-last actor is always the mark. life is the
terminator: an absolute end, fixed when the clock opens, that no bump can pass.
With a decaying extension it ends too suddenly. A grant that shrinks toward
zero makes the last stretch shorter than the time a transaction needs to land,
so whoever is holding at that moment wins by being unreachable rather than by
paying. floor is the anti-snipe: a bump always leaves at least that much on the
clock.
Four invariants, deliberately in the library and not in the caller:
An expired clock never restarts. A late action cannot reopen a settled
game, which is the difference between a closed pot and a stolen one.
The deadline never moves backwards. A small grant late in the game leaves
the existing deadline alone instead of shortening it.
A bump always leaves at least the floor, however little there was to share.
The deadline never passes the hard end, against any grant, including one
that would overflow an int64.
The caller owns the policy, the clock owns the guards.BumpBy takes
whatever grant the game computed: a constant, a function of the amount paid,
anything. BumpShare is the one worth knowing, and it is a share of the distance
to the hard end, not of the time remaining: now + (deadline-now)*pct/100 is
always before the deadline, so the obvious formula extends nothing at all. A
share of the gap to the hard end makes each action close a fraction of what is
left, so the grants shrink as the game runs, which is the pressure a constant
grant never relieves.
Times are int64 and the unit is the caller's, block heights or unix seconds, as
long as it is consistent. Nothing here reads the chain, so a realm can test its
whole endgame without one.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.
Overview
Package clock is a deadline that actions push forward, with the two guards a naive "extend on every action" clock lacks.
Any last-action-wins game has the same clock: every action buys more time, so the game ends only when nobody acts. Written naively it has two failure modes, and both have been observed on chain rather than reasoned about.
The first is that it never ends. If the grant per action is constant, a contested pot is extended forever and the prize is always worth one more action. Life is the terminator: an absolute end, fixed when the clock opens, that no bump can pass. A game with real money in it needs one.
The second is the opposite, and it shows up as soon as the grant decays to relieve the pressure of the first: a grant that is a share of the life left shrinks toward zero near the end, so the last stretch becomes too short for a transaction to land in, and whoever is holding when that happens simply wins. Floor is the anti-snipe: a bump always leaves at least that much on the clock, however little is left to share.
Times are int64 and the unit is the caller's: block heights or unix seconds, as long as it is consistent. Nothing here reads the chain, so a realm can test its whole endgame without one.
1var( 2// ErrBadWindow is returned when the opening grant is not positive. 3ErrBadWindow=errors.New("clock: window must be positive") 4// ErrBadFloor is returned when the floor is negative or exceeds the window. 5ErrBadFloor=errors.New("clock: floor must be between zero and the window") 6// ErrBadLife is returned when the life is negative, or positive but shorter 7// than the opening window, which would close the clock before it opened. 8ErrBadLife=errors.New("clock: life must be zero (unbounded) or at least the window") 9// ErrBadShare is returned when a share is outside 0..100.10ErrBadShare=errors.New("clock: share must be a percentage between 0 and 100")11// ErrUnbounded is returned when a share of the remaining life is asked of a12// clock that has no hard end to measure against.13ErrUnbounded=errors.New("clock: a share of the life needs a bounded clock")14// ErrOverflow is returned when the requested times do not fit in an int64.15ErrOverflow=errors.New("clock: times overflow int64")16)
window is what a plain Bump grants. floor is the minimum a bump leaves on the clock, and must not exceed the window (a floor above the window would mean every bump granting more than the window, which is not a floor). life is the total lifetime from now, after which the deadline can no longer move; zero leaves the clock unbounded, which is the shape that never terminates, so pass it deliberately.
Clock is a deadline plus the three bounds that make it terminate: the window a bump grants, the floor a bump always leaves, and the life it can never pass.
BumpBy extends the deadline to now+grant and returns the new deadline.
The caller owns the policy, so grant is whatever it wants: a constant, a share of what is left, a function of the amount paid. The clock owns the four invariants that policy keeps getting wrong:
An expired clock never restarts. Once it has run out the deadline is frozen, so a late action cannot reopen a settled game.
The deadline never moves backwards. A small grant late in the game leaves the existing deadline alone rather than shortening it.
A bump always leaves at least the floor on the clock, so a decaying grant cannot be shaved below the time a transaction needs to land.
BumpShare grants pct percent of the time left until the hard end, and is the decaying grant a converging game wants.
A grant that is a share of what is left on the DEADLINE cannot extend anything: now+(deadline-now)*pct/100 is always before the deadline itself. A share of the distance to the hard end is the one that works. Each action closes a fraction of the gap, so the deadline crawls toward the hard end and the grants shrink as it does, which is exactly the "the pot is worth one more action" pressure a fixed grant never relieves. The floor is what stops the tail of that curve from becoming too short to act in.
It requires a bounded clock: there is no distance to share without one.
Expired reports whether the clock has run out at now. The deadline itself is past it: a clock due at 100 is expired at 100, so an action and an expiry can never both be valid at the same instant.
Final reports whether the deadline has reached the hard end, so no further bump can move it. A game should say so on its page: it is the only moment at which holding is worth more than acting.