# The caller sequence — a cross-platform contract *This document is CC0, and so are the conformance vectors beside it. The mechanism it specifies was dedicated to the public domain when it was published — see claim 17 of ["An Impartially-Called Circulation Sport for the AI Age"](https://thonly.org/research/the-sport-that-says-your-name). Build a client for sey with it; no permission is needed and none can be withheld.* *It is published for a reason that is part of the mechanism rather than a courtesy: the sport's central promise is that touches are distributed equally yet randomly, and that is the one promise a participant cannot check by watching, since over any short window a fair sequence and a rigged one look alike. Because the sequence is a pure function of the roster and a seed declared before play, anyone holding this document can recompute a finished session and see for themselves that nobody was skipped and nobody was protected. Fairness you can recompute outlasts the organization promising it.* --- Two devices in one circle each compute the call sequence **independently**. Only a call *count* crosses the network; nobody transmits who is next. That is what keeps play working with no connectivity, and it only holds if every client produces **bit-identical** output from the same `(seed, players)`. A client that is one call out of step calls a different name than the circle just heard, with no error to say why. **This document is the contract. Do not reimplement from reading the TypeScript.** Reference implementation: `web/src/caller.ts` in the B-Sey™ client, which is not public — this document is normative, and is meant to be sufficient without it. Conformance vectors: [`caller-vectors.json`](./caller-vectors.json). --- ## 1. The generator — mulberry32 State is a **32-bit** integer. Every operation wraps at 32 bits. ``` function seededRng(seed): a := int32(seed) on each call: a := int32(a + 0x6D2B79F5) t := imul(a XOR (a >>> 15), a OR 1) t := int32( (t + imul(t XOR (t >>> 7), t OR 61)) XOR t ) return uint32(t XOR (t >>> 14)) / 4294967296 ``` where - `>>>` is a **logical** (unsigned, zero-filling) right shift - `imul(x, y)` is 32-bit multiplication that **discards overflow** — not 64-bit multiplication truncated afterwards in a wider type - `int32` / `uint32` reinterpret the same 32 bits, signed or unsigned - `a OR 1` and `t OR 61` are bitwise OR, not addition **The returned double is exact.** The numerator is an integer below 2³² and the divisor is a power of two, so the quotient is representable in IEEE-754 without rounding. There is no floating-point drift to worry about here or in §2. ### Swift ```swift struct SeededRNG { private var a: UInt32 init(seed: UInt32) { a = seed } mutating func next() -> Double { a = a &+ 0x6D2B79F5 var t = (a ^ (a >> 15)) &* (a | 1) t = (t &+ ((t ^ (t >> 7)) &* (t | 61))) ^ t return Double(t ^ (t >> 14)) / 4294967296.0 } } ``` `&+` and `&*` are the wrapping operators — plain `+` and `*` trap on overflow in Swift and **will crash**, which is at least loud. `>>` on `UInt32` is already logical. ### Kotlin ```kotlin class SeededRng(seed: Int) { private var a: Int = seed fun next(): Double { a += 0x6D2B79F5 var t = (a xor (a ushr 15)) * (a or 1) t = (t + ((t xor (t ushr 7)) * (t or 61))) xor t return ((t xor (t ushr 14)).toLong() and 0xFFFFFFFFL) / 4294967296.0 } } ``` `Int` arithmetic wraps silently, which is what is wanted. Use `ushr`, never `shr`. The `and 0xFFFFFFFFL` is the unsigned reinterpretation before dividing — omit it and every negative result flips sign. ## 2. The shuffle — Fisher-Yates, descending ``` function shuffled(items, rng): out := copy(items) for i from length(out) - 1 down to 1: j := floor(rng() * (i + 1)) swap out[i], out[j] return out ``` Exactly this order. Ascending Fisher-Yates, or drawing `j` before `i`, consumes the generator differently and diverges immediately. `floor(rng() * (i+1))` is exact for any circle size a human can stand in. ## 3. The bag — equal touch Each bag holds an ordered queue and the id it last dealt. ``` setMembers(ids): members := copy(ids) queue := queue filtered to ids present in members # order preserved if queue is empty: refill() refill(): if members is empty: queue := []; return queue := shuffled(members, rng) if length(members) > 1 and queue[0] == last: move queue[0] to the end of queue take(): if members is empty: return null if queue is empty: refill() id := remove first element of queue last := id return id ``` The rotate-on-collision in `refill` is the only case a shuffle cannot rule out: the same person closing one round and opening the next. It is a **rotation**, not a swap. Note `setMembers` **preserves surviving queue order** and only refills when the queue is emptied. Rebuilding the queue on every membership change consumes the generator differently and diverges. ## 4. The circle ``` construct(players, mode, level, seed): receiverRng := seededRng(seed) callerRng := seededRng(uint32(seed XOR 0x9E3779B9)) receivers := Bag(receiverRng) callers := Bag(callerRng) callerId := null callsThisCaller := 0 index := 0 setPlayers(players) ``` Two streams from one seed so the receiver order and the caller order neither collide nor drift. `0x9E3779B9` is the golden-ratio constant; the XOR is computed on **unsigned** 32 bits. ``` setPlayers(players): this.players := copy(players) callers.setMembers(ids of players) if callerId is null or callerId not among players: rotateCaller() else: refreshReceiverPool() refreshReceiverPool(): receivers.setMembers(ids of players excluding callerId) rotateCaller(): callerId := callers.take() callsThisCaller := 0 refreshReceiverPool() callsPerCaller := max(1, length(players) - 1) # unless explicitly overridden ``` The relay caller is **excluded from the receiving pool while calling** and returns when the role rotates. ``` next(): if length(players) < 3: return null if callsThisCaller >= callsPerCaller: rotateCaller() roundStart := (receivers.remaining == 0) receiverId := receivers.take() if receiverId is null or callerId is null: return null callsThisCaller += 1 index += 1 return { receiver, caller, index, roundStart, ...level properties } ``` **Order matters.** Rotation is checked *before* the receiver is drawn, and `roundStart` is read *before* `take()` refills the queue. ## 5. Catching up ``` fastForward(toIndex): while index < toIndex and next() is not null: discard ``` A client that joins late, reconnects or reloads rebuilds the circle from the same `(seed, players)` and fast-forwards to the shared count. A few hundred shuffles is nothing. **Players must be in the same order on every client.** The bag shuffles the member list, so a differently ordered roster is a different shuffle. Transmit the roster as an ordered list, never as a set or a dictionary. ## 6. Conformance `caller-vectors.json` carries generated cases: | Case | Checks | |---|---| | `prng_seed_*` | eight raw `uint32` draws and their scaled doubles | | `shuffle_seed_*` | one seven-element shuffle | | `sequence_n*_seed*` | twenty-four `(index, caller, receiver)` triples, at n = 3, 7 and 12 | Compare the `uint32` column first — if that is wrong, nothing downstream can be right, and the cause is almost always a signed shift or a non-wrapping multiply. Regenerate after any change to the reference: ```bash cd web && node ../shared/generate-vectors.mjs ``` Changing the sequence for an existing seed is a **breaking change**: circles saved on one client will replay differently on another. If it ever becomes necessary, version the seed rather than the algorithm. ## 7. What this contract is protecting The determinism is not a performance trick. It is what lets a second phone join a circle without a live connection, and it is what makes the distribution **auditable** — you can reproduce exactly who was called and when, which for a sport whose central promise is fairness is worth more than the sync. Both properties die silently if two clients disagree by one draw.