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

v0 source pure

Package clock is a deadline that actions push forward, with the two guards a naive "extend on every action" clock lacks.

Readme View source

gno.land/p/moul/x/games/clock/v0

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"
2
3c, _ := clock.New(height, 2000, 300, 100000) // now, window, floor, life
4c.Deadline()          // height+2000
5c.BumpShare(h, 20)    // a write buys a fifth of the gap to the hard end
6c.Expired(h)          // the game is over, and cannot be reopened
7c.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.

Live game: r/moul/x/games/lastwords · render it at /r/moul/x/games/lastwords/v0.


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.

A game built on this package is at r/moul/x/games/lastwords(/r/moul/x/games/lastwords/v0).

Variables 1

var ErrBadWindow, ErrBadFloor, ErrBadLife, ErrBadShare, ErrUnbounded, ErrOverflow

 1var (
 2	// ErrBadWindow is returned when the opening grant is not positive.
 3	ErrBadWindow = errors.New("clock: window must be positive")
 4	// ErrBadFloor is returned when the floor is negative or exceeds the window.
 5	ErrBadFloor = 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.
 8	ErrBadLife = errors.New("clock: life must be zero (unbounded) or at least the window")
 9	// ErrBadShare is returned when a share is outside 0..100.
10	ErrBadShare = 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 a
12	// clock that has no hard end to measure against.
13	ErrUnbounded = 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.
15	ErrOverflow = errors.New("clock: times overflow int64")
16)
source

Functions 1

func New

1func New(now, window, floor, life int64) (*Clock, error)
source

New opens a clock at now, due now+window.

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.

Types 1

type Clock

struct
1type Clock struct {
2	start    int64
3	deadline int64
4	window   int64
5	floor    int64
6	life     int64 // 0 means unbounded
7}
source

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.

The zero Clock is not usable; build one with New.

Methods on Clock

func Bump

method on Clock
1func (c *Clock) Bump(now int64) int64
source

Bump extends the deadline by the window. See BumpBy for the guards.

func BumpBy

method on Clock
1func (c *Clock) BumpBy(now, grant int64) int64
source

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.
  • The deadline never passes the hard end.

func BumpShare

method on Clock
1func (c *Clock) BumpShare(now, pct int64) (int64, error)
source

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.

func Deadline

method on Clock
1func (c *Clock) Deadline() int64
source

Deadline returns the time the clock currently expires at.

func Elapsed

method on Clock
1func (c *Clock) Elapsed(now int64) int64
source

Elapsed returns how long the clock has been open at now, never negative.

func Expired

method on Clock
1func (c *Clock) Expired(now int64) bool
source

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.

func Final

method on Clock
1func (c *Clock) Final() bool
source

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.

func Floor

method on Clock
1func (c *Clock) Floor() int64
source

Floor returns the minimum a bump leaves on the clock.

func HardEnd

method on Clock
1func (c *Clock) HardEnd() int64
source

HardEnd returns the time no bump can push the deadline past, or zero when the clock is unbounded.

func Life

method on Clock
1func (c *Clock) Life() int64
source

Life returns the configured lifetime, zero when unbounded.

func Remaining

method on Clock
1func (c *Clock) Remaining(now int64) int64
source

Remaining returns how much time is left at now, never negative.

func Start

method on Clock
1func (c *Clock) Start() int64
source

Start returns when the clock opened.

func Window

method on Clock
1func (c *Clock) Window() int64
source

Window returns the grant a plain Bump aims for.

Imports 1

  • errors stdlib

Source Files 4