diff --git a/README.md b/README.md index a3383a8..0c70a60 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # The Button -A web game of patience and nerve. A 24-hour countdown resets every time any player presses the button. If the countdown ever reaches zero, the game is over forever, and the final global stats are published. +A web game of patience and nerve. A 24-hour countdown resets every time any player presses the button. If it reaches zero, that season's game ends and final stats are published. Each equinox and solstice brings a fresh season. Inspired by Reddit's [The Button](https://en.wikipedia.org/wiki/The_Button_(Reddit)) experiment. @@ -31,6 +31,20 @@ The Chat section is a simple public chatroom. Logged-in players can post plain-t Every successful button press also posts an automatic chat announcement with the player's name, exact time remaining, and points earned. These announcements are saved with the press and do not count toward the player's chat cooldown. +## Seasons and champions + +Seasons begin at **00:00 UTC on the UTC date of each astronomical equinox or solstice**, not at the event's exact hour and not on fixed March/June/September/December dates. They are displayed as **Season I, Season II, Season III**, etc., including in the subtitle under **THE BUTTON**. Numbering begins at the earliest saved season (including existing archives on upgrade); its origin is persisted so numbering remains stable across restarts. Each astronomical boundary advances the number, including seasons missed during downtime. Date-based internal IDs and start/end dates remain unchanged. The built-in Meeus equinox/solstice calculation (with periodic and Delta-T corrections) supports 2000–2100 without network access or dependencies. The 2026 boundaries are March 20, June 21, September 23, and December 21; the December 2027 boundary is December 22. Calendar tests check these against USNO data. The far-future calendar is an astronomical estimate; extend/revalidate it before 2101. + +At each boundary the server archives the old season and starts a fresh countdown anchored to that midnight. Seasonal points, press history, personal bests, achievements, and milestone progress reset. Accounts, password hashes, login sessions, mailing-list subscriptions, and chat are preserved. Previous press histories remain stored in the season archive; public archives expose only score summaries, not private per-player histories. Old milestone announcements remain visible in chat. + +A season finishes when the countdown expires **or** the next seasonal boundary is reached. Pressing cannot extend the timer beyond that boundary. If the timer expires early, pressing and new registrations remain unavailable until the next season; existing players can still log in and chat. The watcher continues running and also catches up boundaries after downtime. A missed season starts at its scheduled midnight, not when the server restarts: without presses its countdown may already be over. Missed seasons have no invented winners. + +The **Seasons** section shows the current season and next start, a champions table, and expandable final standings. Each result shows the winner(s), points per winner, and how long play lasted from actual game start to timeout/boundary (not the calendar season's full length). Equal top scores share the win; alphabetical ordering only orders the display. A player must have pressed at least once to win; a zero-point press qualifies, but an idle account does not. A season without presses has no winner. + +**Upgrade behavior:** the existing JSON/SQLite game is adopted unchanged into the calendar season containing the upgrade time, including all old scores and presses. Its actual original start is retained for duration reporting, even if it predates that calendar season. A brand-new installation similarly starts a partial first season immediately rather than fabricating past play. All subsequent seasons begin at the scheduled UTC midnight. Archives and current-season metadata are added automatically in SQLite; no manual conversion is needed. Keep a database backup before deployment. The first watcher/state request archives a game that was already ended; historical seasons are not reconstructed from an undivided pre-season game. + +Public `GET /api/seasons` returns `{current, seasons}` with current boundary metadata/standings and immutable finished results. `GET /api/state` includes `season` metadata. The main leaderboard, achievements, rivals, and final statistics refer to the current season. Client views refresh across seasonal boundaries without a reload; local alert preferences remain enabled but alerts are re-baselined for the new season. One-life mode is still separate and unimplemented. + ## Engagement roadmap The goal is to make waiting social and consequential without rewarding button spam or changing the current game's rules. Planned delivery order: @@ -169,6 +183,7 @@ Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing. | POST | `/api/press` | ✓ | Press the button | | GET | `/api/me` | ✓ | Your score, zone counts, and press history | | GET | `/api/leaderboard` | | Public player names, total points, and press counts | +| GET | `/api/seasons` | | Current season and archived champions, scores, durations, and standings | | GET | `/api/chat` | | Latest 100 public chat messages | | POST | `/api/chat` | ✓ | `{message}` → saved message; 200-character limit | | GET | `/api/presence` | | Opted-in online count and chat display names | diff --git a/server.py b/server.py index 94781d6..59ec27a 100644 --- a/server.py +++ b/server.py @@ -4,6 +4,7 @@ import csv import hashlib import hmac import json +import math import os import re import secrets @@ -11,6 +12,8 @@ import sqlite3 import threading import time from contextlib import contextmanager +from datetime import datetime, timezone +from functools import lru_cache from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -74,6 +77,107 @@ def now_ms(): return int(time.time() * 1000) +@lru_cache(maxsize=64) +def season_boundaries(year): + """UTC midnights of astronomical events (Meeus, Astronomical Algorithms ch. 27). + + Uses the 2000-era equinox/solstice polynomials and periodic correction, then + a Delta-T estimate to convert terrestrial time to UTC before taking the date. + Dates, not the event's hour, define the game boundary. Supports 2000-2100; + 2026/2027 UTC dates are regression checked against USNO data. + """ + if not 1999 <= year <= 2101: + raise ValueError("Season calendar supports years 2000-2100") + y = (year - 2000) / 1000 + coefficients = ( + (2451623.80984, 365242.37404, .05169, -.00411, -.00057), + (2451716.56767, 365241.62603, .00325, .00888, -.00030), + (2451810.21715, 365242.01767, -.11575, .00337, .00078), + (2451900.05952, 365242.74049, -.06223, -.00823, .00032), + ) + terms = ( + (485, 324.96, 1934.136), (203, 337.23, 32964.467), (199, 342.08, 20.186), + (182, 27.85, 445267.112), (156, 73.14, 45036.886), (136, 171.52, 22518.443), + (77, 222.54, 65928.934), (74, 296.72, 3034.906), (70, 243.58, 9037.513), + (58, 119.81, 33718.147), (52, 297.17, 150.678), (50, 21.02, 2281.226), + (45, 247.54, 29929.562), (44, 325.15, 31555.956), (29, 60.93, 4443.417), + (18, 155.12, 67555.328), (17, 288.79, 4562.452), (16, 198.04, 62894.029), + (14, 199.76, 31436.921), (12, 95.39, 14577.848), (12, 287.11, 31931.756), + (12, 320.81, 34777.259), (9, 227.73, 1222.114), (8, 15.45, 16859.074), + ) + result = [] + for index, coefficients_for_event in enumerate(coefficients): + jde = sum(c * y ** power for power, c in enumerate(coefficients_for_event)) + t = (jde - 2451545) / 36525 + w = math.radians(35999.373 * t - 2.47) + correction = sum(a * math.cos(math.radians(b + c * t)) for a, b, c in terms) + jde += .00001 * correction / (1 + .0334 * math.cos(w) + .0007 * math.cos(2 * w)) + decimal_year = year + (index * 3 + 2.5) / 12 + u = decimal_year - 2000 + if decimal_year < 2005: + delta_t = 63.86 + .3345 * u - .060374 * u**2 + .0017275 * u**3 + .000651814 * u**4 + .00002373599 * u**5 + elif decimal_year < 2050: + delta_t = 62.92 + .32217 * u + .005589 * u**2 + else: + delta_t = -20 + 32 * ((decimal_year - 1820) / 100)**2 - .5628 * (2150 - decimal_year) + event = datetime.fromtimestamp((jde - 2440587.5) * 86400 - delta_t, timezone.utc) + midnight = event.replace(hour=0, minute=0, second=0, microsecond=0) + result.append({"id": midnight.strftime("%Y-%m-%d"), + "name": f"{('March equinox', 'June solstice', 'September equinox', 'December solstice')[index]} {year}", + "starts_at": int(midnight.timestamp() * 1000)}) + return tuple(result) + + +def season_for_time(at): + year = datetime.fromtimestamp(at / 1000, timezone.utc).year + if not 2000 <= year <= 2100: + raise ValueError("Season calendar supports years 2000-2100") + boundaries = [entry for y in (year - 1, year, year + 1) for entry in season_boundaries(y)] + index = max(i for i, entry in enumerate(boundaries) if entry["starts_at"] <= at) + return {**boundaries[index], "ends_at": boundaries[index + 1]["starts_at"]} + + +def ensure_season(connection, at=None): + """Adopt the existing game into the current season without deleting its history.""" + if connection.execute("SELECT 1 FROM season_clock WHERE singleton = 1").fetchone() is None: + season = season_for_time(now_ms() if at is None else at) + connection.execute( + "INSERT INTO season_clock (singleton, id, name, starts_at, ends_at) VALUES (1, ?, ?, ?, ?)", + (season["id"], season["name"], season["starts_at"], season["ends_at"]), + ) + connection.execute("""INSERT OR IGNORE INTO season_numbering (singleton, origin_at) + SELECT 1, MIN(starts_at) FROM ( + SELECT starts_at FROM season_clock UNION ALL SELECT starts_at FROM season_results + )""") + + +def roman_numeral(number): + if number < 1: + raise ValueError("Season numbers must be positive") + result = [] + for value, symbol in ((1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), + (90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"), + (5, "V"), (4, "IV"), (1, "I")): + count, number = divmod(number, value) + result.append(symbol * count) + return "".join(result) + + +def numbered_season(connection, season): + origin = connection.execute("SELECT origin_at FROM season_numbering WHERE singleton = 1").fetchone()[0] + def ordinal(at): + date = datetime.fromtimestamp(at / 1000, timezone.utc) + return date.year * 4 + (date.month - 3) // 3 + number = ordinal(season["starts_at"]) - ordinal(origin) + 1 + return {**season, "number": number, "name": f"Season {roman_numeral(number)}"} + + +def current_season(connection): + row = dict(connection.execute("SELECT * FROM season_clock WHERE singleton = 1").fetchone()) + del row["singleton"] + return numbered_season(connection, row) + + def hash_token(token): return hashlib.sha256(token.encode("utf-8")).hexdigest() @@ -239,6 +343,24 @@ def load_state(): announced_at INTEGER, message TEXT ); + CREATE TABLE IF NOT EXISTS season_clock ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + id TEXT NOT NULL, name TEXT NOT NULL, + starts_at INTEGER NOT NULL, ends_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS season_results ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, + starts_at INTEGER NOT NULL, ends_at INTEGER NOT NULL, + played_from INTEGER NOT NULL, finished_at INTEGER NOT NULL, + reason TEXT NOT NULL, leaderboard TEXT NOT NULL, presses TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS season_milestone_messages ( + id TEXT PRIMARY KEY, at INTEGER NOT NULL, message TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS season_numbering ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + origin_at INTEGER NOT NULL + ); """) columns = {row["name"] for row in connection.execute("PRAGMA table_info(chat_messages)")} if "kind" not in columns: @@ -260,6 +382,7 @@ def load_state(): # JSON source or committing a partial game. The next start can retry. write_state(connection, loaded) sync_milestones(connection, loaded) + ensure_season(connection) return loaded loaded = dict(game) @@ -280,6 +403,7 @@ def load_state(): del press["position"] loaded["presses"].append(press) sync_milestones(connection, loaded) + ensure_season(connection) return loaded @@ -322,6 +446,9 @@ def chat_messages(): UNION ALL SELECT 'milestone:' || id, 'The Button', announced_at, message, 'milestone', 1, rowid FROM community_milestones WHERE announced_at IS NOT NULL + UNION ALL + SELECT id, 'The Button', at, message, 'milestone', 1, rowid + FROM season_milestone_messages ) ORDER BY at DESC, source DESC, position DESC LIMIT ?""", (CHAT_HISTORY_LIMIT,), ).fetchall() @@ -361,15 +488,96 @@ def validate_credentials(data): return name, password, None -def check_ended(): - """Must be called with the lock held.""" - if not state["ended"] and now_ms() >= state["deadline"]: - state["ended"] = True - state["ended_at"] = state["deadline"] - save_state(state) - else: - with database() as connection: - sync_milestones(connection, state) +def archive_season(connection, season, game_state, finished_at, reason): + """Immutable score/history snapshots have no FK to mutable player rows.""" + if connection.execute("SELECT 1 FROM season_results WHERE id = ?", (season["id"],)).fetchone(): + return + scores = {pid: {"name": p["name"], "total_points": 0, "press_count": 0} + for pid, p in game_state["players"].items()} + for press in game_state["presses"]: + scores[press["player_id"]]["total_points"] += press["points"] + scores[press["player_id"]]["press_count"] += 1 + standings = sorted(scores.values(), key=lambda p: (-p["total_points"], p["name"].lower())) + connection.execute( + """INSERT OR IGNORE INTO season_results + (id, name, starts_at, ends_at, played_from, finished_at, reason, leaderboard, presses) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (season["id"], season["name"], season["starts_at"], season["ends_at"], + game_state["started_at"], max(game_state["started_at"], finished_at), reason, + json.dumps(standings), json.dumps(game_state["presses"])), + ) + + +def check_ended(at=None): + """Advance season and game atomically; caller must hold the game lock.""" + t = now_ms() if at is None else at + replacement = None + with database() as connection: + connection.execute("BEGIN IMMEDIATE") + season = current_season(connection) + while t >= season["ends_at"]: + previous = replacement if replacement is not None else state + finish = min(previous["ended_at"] if previous["ended_at"] is not None else previous["deadline"], season["ends_at"]) + archive_season(connection, season, previous, finish, + "countdown" if finish < season["ends_at"] else "boundary") + # Preserve old milestone announcements before resetting seasonal goals. + connection.execute( + """INSERT OR IGNORE INTO season_milestone_messages (id, at, message) + SELECT ? || id, announced_at, message FROM community_milestones + WHERE announced_at IS NOT NULL""", (f"milestone:{season['id']}:",), + ) + connection.execute("DELETE FROM community_milestones") + season = season_for_time(season["ends_at"]) + connection.execute( + "UPDATE season_clock SET id = ?, name = ?, starts_at = ?, ends_at = ? WHERE singleton = 1", + (season["id"], season["name"], season["starts_at"], season["ends_at"]), + ) + replacement = {**previous, "duration_ms": DURATION_MS, "started_at": season["starts_at"], + "deadline": season["starts_at"] + DURATION_MS, "ended": False, + "ended_at": None, "presses": []} + current = replacement if replacement is not None else state + if not current["ended"] and current["deadline"] > season["ends_at"]: + current = {**current, "deadline": season["ends_at"]} + replacement = current + if not current["ended"] and t >= current["deadline"]: + current = {**current, "ended": True, "ended_at": current["deadline"]} + replacement = current + if replacement is not None: + write_state(connection, current) + sync_milestones(connection, current, min(t, season["ends_at"])) + if current["ended"]: + archive_season(connection, season, current, current["ended_at"], + "boundary" if current["ended_at"] == season["ends_at"] else "countdown") + if replacement is not None: + state.clear() + state.update(replacement) + + +def seasonal_leaderboard(): + with database() as connection: + season = current_season(connection) + rows = connection.execute("""SELECT id, name, starts_at, ends_at, played_from, + finished_at, reason, leaderboard FROM season_results ORDER BY starts_at DESC""").fetchall() + rows = [numbered_season(connection, dict(row)) for row in rows] + archives = [] + for row in rows: + standings = json.loads(row["leaderboard"]) + participants = [player for player in standings if player["press_count"] > 0] + top = max((p["total_points"] for p in participants), default=None) + archives.append({ + "id": row["id"], "name": row["name"], "number": row["number"], "starts_at": row["starts_at"], + "ends_at": row["ends_at"], "played_from": row["played_from"], "finished_at": row["finished_at"], + "duration_ms": max(0, row["finished_at"] - row["played_from"]), "reason": row["reason"], + "winners": [p for p in participants if p["total_points"] == top], + "leaderboard": standings, + }) + return {"current": {**season, "played_from": state["started_at"], "ended": state["ended"], + "leaderboard": public_leaderboard()}, "seasons": archives} + + +def season_summary(): + with database() as connection: + return {**current_season(connection), "played_from": state["started_at"], "ended": state["ended"]} def empty_zone_counts(): @@ -590,9 +798,15 @@ class Handler(BaseHTTPRequestHandler): "total_players": len(state["players"]), "total_presses": len(state["presses"]), "zones": ZONES, + "season": season_summary(), }) + if path == "/api/seasons": + with lock: + check_ended() + return self.send_json(HTTPStatus.OK, seasonal_leaderboard()) if path == "/api/leaderboard": with lock: + check_ended() return self.send_json(HTTPStatus.OK, {"leaderboard": public_leaderboard()}) if path == "/api/chat": return self.send_json(HTTPStatus.OK, {"messages": chat_messages()}) @@ -601,6 +815,7 @@ class Handler(BaseHTTPRequestHandler): return self.send_json(HTTPStatus.OK, public_presence()) if path == "/api/me": with lock: + check_ended() pid = self.authed_player() if pid is None: return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"}) @@ -759,10 +974,10 @@ class Handler(BaseHTTPRequestHandler): pid = self.authed_player() if pid is None: return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"}) - check_ended() + t = now_ms() + check_ended(t) if state["ended"]: return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"}) - t = now_ms() remaining = state["deadline"] - t zone = zone_for_remaining(remaining) press = { @@ -773,7 +988,9 @@ class Handler(BaseHTTPRequestHandler): "points": zone["points"], } state["presses"].append(press) - state["deadline"] = t + DURATION_MS + with database() as connection: + season_end = current_season(connection)["ends_at"] + state["deadline"] = min(t + DURATION_MS, season_end) hours, remainder = divmod(max(0, remaining), 3600000) minutes, remainder = divmod(remainder, 60000) seconds, milliseconds = divmod(remainder, 1000) @@ -866,9 +1083,10 @@ def end_watcher(): while True: with lock: check_ended() - if state["ended"]: - return - wait = (state["deadline"] - now_ms()) / 1000 + with database() as connection: + next_boundary = current_season(connection)["ends_at"] + wake_at = next_boundary if state["ended"] else min(state["deadline"], next_boundary) + wait = (wake_at - now_ms()) / 1000 time.sleep(min(max(wait, 0.05), 60)) diff --git a/static/app.js b/static/app.js index 5127da9..bd830ee 100644 --- a/static/app.js +++ b/static/app.js @@ -218,6 +218,7 @@ function renderSections() { const available = { "account-section": true, "leaderboard-section": true, + "seasons-section": true, "chat-section": true, "alerts-section": true, "stats-section": statsLoaded, @@ -251,6 +252,7 @@ $("section-nav").addEventListener("click", (event) => { if (selectedSection === "account-section") loadMe(); if (selectedSection === "leaderboard-section") loadLeaderboard(); if (selectedSection === "chat-section") loadChat(); + if (selectedSection === "seasons-section") loadSeasons(); syncPresence(); }); @@ -387,8 +389,20 @@ async function refreshState() { const t0 = Date.now(); const s = await api("GET", "/api/state"); const first = !game; + const changedSeason = game && game.season?.id !== s.season?.id; + if (changedSeason) { + statsLoaded = false; + currentShareText = ""; + alertObservation = null; + $("press-result").textContent = ""; + $("me-section").hidden = true; + } clockOffset = s.now - (t0 + Date.now()) / 2; game = s; + if (s.season) { + $("season-subtitle").textContent = s.season.name; + $("season-label").textContent = `${s.ended ? "Season finished. " : ""}Next season: ${new Date(s.season.ends_at).toISOString().slice(0, 10)} UTC.`; + } observeZoneAlerts(s); $("history-global-summary").textContent = `There have been ${s.total_presses.toLocaleString()} total presses and there are ${s.total_players.toLocaleString()} total players.`; @@ -399,6 +413,7 @@ async function refreshState() { await loadLeaderboard(); if (selectedSection === "chat-section") await loadChat(); if (selectedSection === "account-section") await loadMe(); + if (selectedSection === "seasons-section") await loadSeasons(); } function updateButton() { @@ -417,7 +432,7 @@ function tick() { cd.textContent = formatMs(remaining); setZoneClass(cd, zone.name); setZoneClass($("the-button"), zone.name); - $("zone-label").textContent = game.ended ? "game over" : `${zone.name} zone \u00b7 ${zone.points} pt${zone.points === 1 ? "" : "s"}`; + $("zone-label").textContent = game.ended ? "season finished" : `${zone.name} zone \u00b7 ${zone.points} pt${zone.points === 1 ? "" : "s"}`; const pct = Math.min(100, Math.max(0, ((game.duration_ms - remaining) / game.duration_ms) * 100)); $("zone-marker").style.left = `${pct}%`; if (!game.ended && remaining <= 0) refreshState().catch(() => {}); @@ -513,6 +528,49 @@ async function loadMe() { } } +function seasonDuration(ms) { + const days = Math.floor(ms / 86400000); + return `${days ? `${days}d ` : ""}${formatMs(ms % 86400000).slice(0, -3)}`; +} + +async function loadSeasons() { + try { + const result = await api("GET", "/api/seasons"); + const current = result.current; + $("season-current").replaceChildren( + el("p", "CURRENT SEASON", "eyebrow"), el("h3", current.name), + el("p", `${current.ended ? "Finished - next countdown" : "Next season"}: ${new Date(current.ends_at).toISOString().slice(0, 10)} at 00:00 UTC`, "fine-print"), + ); + $("seasons-status").textContent = result.seasons.length ? "" : "No finished seasons yet. This chapter is still being written."; + $("season-winners").hidden = result.seasons.length === 0; + fillTable($("season-winners"), ["Season", "Winner(s)", "Points each", "Lasted"], result.seasons.map((season) => [ + season.name, season.winners.map((p) => p.name).join(", ") || "No winner (no presses)", + season.winners.length ? season.winners[0].total_points.toLocaleString() : "--", seasonDuration(season.duration_ms), + ])); + const container = $("season-standings"); + // Do not replace focused/open archive details on every poll. + const signature = JSON.stringify(result.seasons); + if (container.dataset.signature !== signature) { + container.dataset.signature = signature; + container.replaceChildren(...result.seasons.map((season) => { + const details = el("details"); + details.append(el("summary", `${season.name} - final standings`)); + details.append(el("p", `Played from ${new Date(season.played_from).toLocaleString()} until ${new Date(season.finished_at).toLocaleString()}. Ended by ${season.reason === "boundary" ? "the next season" : "the countdown"}.`, "fine-print")); + const wrapper = el("div", undefined, "table-scroll"); + const table = el("table"); + fillTable(table, ["#", "Player", "Points", "Presses"], season.leaderboard.map((player, index) => [ + index + 1, player.name, player.total_points.toLocaleString(), player.press_count.toLocaleString(), + ])); + wrapper.append(table); + details.append(wrapper); + return details; + })); + } + } catch { + $("seasons-status").textContent = "Seasons could not be refreshed. Retrying automatically..."; + } +} + async function loadLeaderboard() { try { const { leaderboard } = await api("GET", "/api/leaderboard"); @@ -592,8 +650,10 @@ $("chat-form").addEventListener("submit", async (event) => { }); async function loadStats() { + const seasonId = game?.season?.id; try { const s = await api("GET", "/api/stats"); + if (game?.season?.id !== seasonId) return; statsLoaded = true; renderSections(); const summary = $("stats-summary"); diff --git a/static/index.html b/static/index.html index 33b2e54..0a8b1c9 100644 --- a/static/index.html +++ b/static/index.html @@ -19,6 +19,7 @@
A SHARED EXPERIMENT
Loading season…
One clock. Everyone's nerve.
Checking who's here…
+A NEW CHAPTER, FOUR TIMES A YEAR
+New scores. Familiar faces. Every equinox and solstice brings a fresh start.
+Starts at 00:00 UTC on the astronomical equinox/solstice date. Joint top scorers share the win. Only players who pressed can win, including zero-point presses.
+Loading seasons…
+WAIT ON YOUR TERMS
THE LONG GAME
Updates every 5sA little patience. A little nerve. See who's making every second count.
+This season's scores. A little patience. A little nerve. See who's making every second count.
Loading scores…
@@ -213,7 +231,7 @@THE FINAL CHAPTER
The clock has stopped. Here's how we got here.
This season's clock has stopped. Here's how we got here. A new game starts next season.
Every press resets the countdown for everyone.
The lower the countdown, the more you score. Each zone awards a fixed number of points per press.
If the countdown reaches zero, the game is over. Forever.
If the countdown reaches zero, this season's game ends. The next equinox or solstice starts a new countdown and resets scores. Accounts and chat stay.