← Back to the gamebug or idea?Terrapin Trail//Code BibleIIhow the game works, archive to van
Reel 16 · 7 min read

Fair play and shared luck

Run tickets, score caps, rate limits, the name filter, and seeded runs for challenges -- and their limits.

The game runs in the player's browser, so a determined player can always forge a score. The goal here is narrower: stop the casual kinds of cheating, keep the boards free of offensive names, and make head-to-head challenges fair.

Run tickets

When /play starts a shared tour, the server issues a run ticket that records the tag, the rule set, the number of stops, the start time and the run's luck:

●trust.py : 58–63trace ↗
58def start_run(store, tag: str, dna_code: str, stops: int, years: int, luck: int | None) -> dict:
59    run_id = secrets.token_urlsafe(12)
60    seed = luck if isinstance(luck, int) and 0 < luck < 2**31 else secrets.randbelow(2**31 - 1) + 1
61    rec = {"tag": tag, "dna": dna_code, "stops": stops, "years": years, "seed": seed, "t0": time.time()}
62    store.set("run:" + run_id, rec, ttl=RUN_TTL)
63    return {"run": run_id, "seed": seed}
start_run: one ticket per run, kept for three days.

A score must come with its ticket, and each ticket counts once:

●trust.py : 79–100trace ↗
79def check_run(store, run_id: str, rec: dict, legend_pts: int, ip: str) -> tuple[dict | None, str | None]:
80    """Validate a submission against its run ticket. Returns (ticket, None) or (None, reason)."""
81    if ip and store.incr("rl:" + ip, ttl=RATE_WINDOW) > RATE_MAX:
82        return None, "Too many scores from here in the last hour. Try again later."
83    ticket = store.get("run:" + str(run_id)) if run_id else None
84    if not ticket:
85        return None, "That run is already on the board, or it wasn't started from a shared tour."
86    stops, years = ticket["stops"], ticket["years"]
87    if time.time() - ticket["t0"] < MIN_SECONDS_PER_STOP * stops * years:
88        return None, "That run finished faster than the road allows."
89    limits = {
90        "shows": stops * years, "legendary": years, "tapes": 40 * stops * years,
91        "miles": 12000 * years, "day": 400 * years,
92    }
93    for k, cap in limits.items():
94        v = rec.get(k, 0)
95        if not isinstance(v, int) or v < 0 or v > cap:
96            return None, "Those numbers don't fit this tour."
97    if not isinstance(rec.get("score"), int) or not 0 <= rec["score"] <= score_cap(rec, stops, years, legend_pts):
98        return None, "That score doesn't fit this tour."
99    store.delete("run:" + str(run_id))  # each ticket counts once
100    return ticket, None
check_run: rate limit per visitor, a minimum time per stop, every number within what this tour allows, then the ticket is spent.

score_cap reverse-engineers the most a run with the submitted numbers could score, using the same terms as computeScore (including the legendary-tape value from the run's rules), with headroom:

●trust.py : 66–76trace ↗
66def score_cap(rec: dict, stops: int, years: int, legend_pts: int) -> int:
67    """The most a run with these numbers could honestly score, with headroom."""
68    return int(
69        rec.get("tapes", 0) * 8
70        + rec.get("shows", 0) * 15
71        + rec.get("legendary", 0) * legend_pts
72        + 200  # vibes + cred, each capped at 100
73        + 2400  # cash / 5, for up to $12,000 banked
74        + rec.get("miles", 0) / 40
75        + 50
76    )
score_cap: tapes, shows, legendary tapes, capped vibes and cred, banked cash and miles.

Names

●trust.py : 43–55trace ↗
43def clean_name(raw: str) -> tuple[str | None, str | None]:
44    """Return (name, None) if usable, or (None, reason) if not."""
45    swapped = str(raw or "").lower().translate(_SWAPS)
46    name = re.sub(r"\s+", " ", str(raw or "")).strip()
47    name = re.sub(r"[^A-Za-z0-9 _.'\-]", "", name)[:24].strip()
48    if len(name) < 2:
49        return None, "Pick a name with at least 2 letters."
50    letters = _squash(re.sub(r"[^a-z]", "", swapped))
51    words = {_squash(w) for w in re.split(r"[^a-z]+", swapped) if w}
52    bad = any(_squash(w) in letters for w in _BLOCKED_ANYWHERE) or any(_squash(w) in words for w in _BLOCKED_WORDS)
53    if bad:
54        return None, "That name can't go on a public board. Pick another."
55    return name, None
clean_name: undo letter swaps (0->o, 1->i, $->s...), collapse repeats, then check long words anywhere and short words only as whole words.

The two lists exist because of the Scunthorpe problem: matching "rape" anywhere would block "grape", and "cum" would block "Cumberland Blues". Short words only count as whole words. "Dick" is deliberately allowed (Dick Latvala, Dick's Picks).

Same luck

Every random roll in the game goes through one function, rand. A ticket carries a seed, and when the page passes one in, rand becomes a seeded generator instead of Math.random:

●assets/game/game.js : 19–28trace ↗
19  if (typeof window !== "undefined" && window.__TT_SEED__) {
20    var luckSeed = window.__TT_SEED__ >>> 0;
21    rand = function () {
22      luckSeed = (luckSeed + 0x6D2B79F5) >>> 0;
23      var t = luckSeed;
24      t = Math.imul(t ^ (t >>> 15), t | 1);
25      t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
26      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
27    };
28  }
The same mulberry32-style generator the test harness uses, installed at load when a seed is present.

A challenge link carries luck=<seed>, so a friend's run rolls the same roster and the same first surprises. Once their choices differ, the sequence of rolls differs and the runs drift apart; that's inherent to a seeded game, not a bug.

Key takeaways
  • Scores need a one-time run ticket from the server; speed, numbers and rate are checked.
  • This stops casual cheating, not a determined one: the game itself runs in the browser.
  • Names are filtered with whole-word matching for short words, to avoid blocking innocent names.
  • A seed makes challenges start with the same luck; different choices make runs diverge.