diff --git a/server.py b/server.py
index 568e4b0..7802ab9 100644
--- a/server.py
+++ b/server.py
@@ -310,6 +310,19 @@ def public_press(p):
}
+def public_leaderboard():
+ """Public score totals only; do not expose sessions or individual presses."""
+ scores = {
+ pid: {"name": player["name"], "total_points": 0, "press_count": 0}
+ for pid, player in state["players"].items()
+ }
+ for press in state["presses"]:
+ score = scores[press["player_id"]]
+ score["total_points"] += press["points"]
+ score["press_count"] += 1
+ return sorted(scores.values(), key=lambda p: (-p["total_points"], p["name"].lower()))
+
+
def global_stats():
counts = empty_zone_counts()
for p in state["presses"]:
@@ -397,6 +410,9 @@ class Handler(BaseHTTPRequestHandler):
"total_presses": len(state["presses"]),
"zones": ZONES,
})
+ if path == "/api/leaderboard":
+ with lock:
+ return self.send_json(HTTPStatus.OK, {"leaderboard": public_leaderboard()})
if path == "/api/me":
with lock:
pid = self.authed_player()
diff --git a/static/app.js b/static/app.js
index c93318a..1cabb61 100644
--- a/static/app.js
+++ b/static/app.js
@@ -9,6 +9,40 @@ let token = localStorage.getItem(TOKEN_KEY);
let statsLoaded = false;
let subscribed = false;
let currentShareText = "";
+let selectedSection = null;
+
+function renderSections() {
+ const available = {
+ "account-section": true,
+ "leaderboard-section": true,
+ "stats-section": statsLoaded,
+ rules: true,
+ "mailing-list": !!token && !subscribed,
+ "share-controls": !!currentShareText,
+ more: true,
+ donate: true,
+ };
+ if (!available[selectedSection]) selectedSection = null;
+ document.querySelectorAll("[data-section]").forEach((button) => {
+ const id = button.dataset.section;
+ button.hidden = !available[id];
+ button.setAttribute("aria-expanded", String(selectedSection === id));
+ if (id === "account-section") button.textContent = token ? "Your score" : "Log in / Join";
+ });
+ document.querySelectorAll("[data-panel]").forEach((panel) => {
+ panel.hidden = panel.id !== selectedSection;
+ });
+ $("section-hint").hidden = selectedSection !== null;
+}
+
+$("section-nav").addEventListener("click", (event) => {
+ const button = event.target.closest("[data-section]");
+ if (!button) return;
+ selectedSection = selectedSection === button.dataset.section ? null : button.dataset.section;
+ renderSections();
+ if (selectedSection === "account-section") loadMe();
+ if (selectedSection === "leaderboard-section") loadLeaderboard();
+});
async function api(method, path, body) {
const headers = { "Content-Type": "application/json" };
@@ -150,13 +184,15 @@ async function refreshState() {
if (first) buildStaticUi();
if (game.ended && !statsLoaded) loadStats();
updateButton();
+ await loadLeaderboard();
}
function updateButton() {
$("the-button").disabled = !token || !game || game.ended;
$("register-section").hidden = !!token;
- $("mailing-list").hidden = !token || subscribed;
+ $("me-section").hidden = !token;
$("my-subscribed").hidden = !token || !subscribed;
+ renderSections();
}
function tick() {
@@ -179,7 +215,6 @@ async function loadMe() {
if (!token) return;
try {
const me = await api("GET", "/api/me");
- $("me-section").hidden = false;
$("my-name").textContent = me.name;
$("my-points").textContent = me.total_points.toLocaleString();
$("my-presses").textContent = me.press_count;
@@ -202,11 +237,28 @@ async function loadMe() {
}
}
+async function loadLeaderboard() {
+ try {
+ const { leaderboard } = await api("GET", "/api/leaderboard");
+ fillTable(
+ $("leaderboard"),
+ ["Rank", "Player", "Points", "Presses"],
+ leaderboard.map((player, index) => [
+ index + 1, player.name, player.total_points.toLocaleString(), player.press_count.toLocaleString(),
+ ]),
+ );
+ $("leaderboard").hidden = leaderboard.length === 0;
+ $("leaderboard-status").textContent = leaderboard.length ? "" : "No players yet. Be the first to join!";
+ } catch {
+ $("leaderboard-status").textContent = "Scores could not be refreshed. Retrying automatically...";
+ }
+}
+
async function loadStats() {
try {
const s = await api("GET", "/api/stats");
statsLoaded = true;
- $("stats-section").hidden = false;
+ renderSections();
const summary = $("stats-summary");
summary.replaceChildren(
el("p", `Lasted ${formatMs(s.ended_at - s.started_at)} \u00b7 ${s.total_players} players \u00b7 ${s.total_presses} presses`),
@@ -242,8 +294,8 @@ async function loadStats() {
$("the-button").addEventListener("click", async () => {
const btn = $("the-button");
btn.disabled = true;
- $("share-controls").hidden = true;
currentShareText = "";
+ renderSections();
try {
const r = await api("POST", "/api/press");
game.deadline = r.deadline;
@@ -251,8 +303,8 @@ $("the-button").addEventListener("click", async () => {
$("press-result").textContent =
`You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`;
updateShareLinks();
- $("share-controls").hidden = false;
await loadMe();
+ await loadLeaderboard();
} catch (e) {
$("press-result").textContent = e.message;
await refreshState().catch(() => {});
@@ -277,6 +329,7 @@ $("register-form").addEventListener("submit", async (ev) => {
$("password-input").value = "";
updateButton();
await loadMe();
+ await loadLeaderboard();
} catch (e) {
$("register-error").textContent = e.message;
}
diff --git a/static/index.html b/static/index.html
index 4555313..3599865 100644
--- a/static/index.html
+++ b/static/index.html
@@ -16,6 +16,7 @@
+
THE BUTTON
-
+
+
Log in or join the game
-
+
-
+
Your score:
You're on the mailing list. Thanks!
@@ -65,9 +83,19 @@
Your press history
+
-
+
+ Leaderboard
+ See how you stack up. Scores refresh every five seconds.
+ Loading scores…
+
+
+
+
Game over — final global stats
Presses per zone
@@ -80,7 +108,7 @@
-
+
Rules
- A 24 hour countdown resets whenever anyone presses the button.
@@ -88,17 +116,18 @@
- The lower the countdown when you press, the more points you score.
- Zones:
- Each press scores the fixed point value of the zone it lands in.
- - Global stats are published when the game ends.
+ - Player scores are public on the live leaderboard. Detailed global stats are published when the game ends.
- Inspired by the original Reddit "The Button" experiment.
-
+
More
Visit Eric's homepage for contact information, social media, and links to his other projects.
-
+
+ Mailing list
Join the mailing list to receive updates on Eric's projects:
-
+
Donate
Help support my free and open source projects by making a donation through Stripe:
diff --git a/test_server.py b/test_server.py
index 62eddce..59eb716 100755
--- a/test_server.py
+++ b/test_server.py
@@ -157,6 +157,51 @@ class PersistenceTests(unittest.TestCase):
initial = self.game.load_state()
self.assertEqual(initial, self.game.load_state())
+ def test_public_leaderboard_scores_ties_and_zero_press_players(self):
+ self.game.state = self.sample_state()
+ self.game.state["players"]["charlie"] = {"name": "Charlie"}
+ self.game.state["players"]["aaron"] = {"name": "aaron"}
+ self.game.state["presses"].append({
+ "player_id": "aaron", "at": 300, "remaining_ms": 10, "zone": "white", "points": 40,
+ })
+ self.assertEqual(self.game.public_leaderboard(), [
+ {"name": "aaron", "total_points": 40, "press_count": 1},
+ {"name": self.game.state["players"]["alice"]["name"], "total_points": 40, "press_count": 2},
+ {"name": "Bøb", "total_points": 16, "press_count": 1},
+ {"name": "Charlie", "total_points": 0, "press_count": 0},
+ ])
+
+ def test_leaderboard_http_is_public_before_and_after_game_ends(self):
+ self.game.state = self.sample_state()
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler)
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
+ thread.start()
+ self.addCleanup(httpd.server_close)
+ self.addCleanup(thread.join)
+ self.addCleanup(httpd.shutdown)
+
+ def get(path):
+ connection = HTTPConnection(*httpd.server_address, timeout=5)
+ try:
+ connection.request("GET", path)
+ response = connection.getresponse()
+ return response.status, json.loads(response.read()), response.getheader("Cache-Control")
+ finally:
+ connection.close()
+
+ for ended in (False, True):
+ self.game.state["ended"] = ended
+ status, payload, cache = get("/api/leaderboard")
+ self.assertEqual(status, 200)
+ self.assertEqual(cache, "no-store")
+ self.assertEqual(set(payload), {"leaderboard"})
+ self.assertEqual(payload["leaderboard"], self.game.public_leaderboard())
+ for player in payload["leaderboard"]:
+ self.assertEqual(set(player), {"name", "total_points", "press_count"})
+ self.assertEqual(get("/api/stats")[0], 200 if ended else 403)
+ self.game.state = self.game.new_game()
+ self.assertEqual(get("/api/leaderboard")[1], {"leaderboard": []})
+
def test_http_registration_login_press_logout_and_restart(self):
self.game.state = self.game.load_state()
self.game.token_index = {}