Gore opens in the gore, the theme comes back, and every mode gets a board

Splatter gore no longer makes you win a game of tennis first — the mode you
pick is the mode you get, with the man you came to play already turned. The
transition itself is untouched and still reachable; it just isn't a toll booth.

The menu theme could only ever be started, never brought back, so the first
run of a session silenced it for good while the court's ambience followed you
into the menu. Starting a bed now un-ducks it too.

Three world boards instead of one: kills, opponents felled, and games taken
off the CPU, each with its own name prompt and its own row per player.

...and the gore economy stops paying a barrel for every game won past game 16,
while the shamblers, the mix and the ball machines keep escalating past the
point they all used to stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-15 13:53:42 +02:00
1 parent 9cc3ac54b3
commit 3cf3e029a4
11 files changed
+440 -88

No files matched your search

+53 -21
View File
@@ -5,8 +5,11 @@ It replaced the nginx image because the leaderboard needs ONE writable process a
one. Everything nginx was doing here is four lines of headers, so a second container (and a second
Coolify resource, and a proxy rule) would have bought separation we have no use for at this size.
GET /api/scores -> {"scores": [{"n","k","g","t"}, ...]} best first
POST /api/scores <- {"n","k","g"} -> {"ok", "rank", "scores"}
GET /api/scores?m=g|z|c -> {"m", "scores": [{"n","k","g","t","m"}, ...]} best first
POST /api/scores <- {"n","k","g","m"} -> {"ok", "rank", "m", "scores"}
One board per game mode; `m` defaults to 'g' (splatter gore) at both ends, which is what every row
written before the split is.
Scores live in ONE json file under SPLAT_DATA (default /data, which must be a persistent volume —
anything written inside the container image is gone on the next deploy). There is no auth: the
@@ -22,12 +25,23 @@ DATA = os.environ.get('SPLAT_DATA') or ('/data' if os.path.isdir('/data') else H
FILE = os.path.join(DATA, 'scores.json')
PORT = int(os.environ.get('PORT', 80))
KEEP = 200 # rows kept on disk; the board only ever shows the top slice of this
KEEP = 200 # rows kept on disk PER BOARD; the board only ever shows the top slice of this
MAX_BODY = 2048 # a submission is ~60 bytes. Anything larger is not one.
NAME_MAX = 12
# ONE BOARD PER GAME MODE (2026-08-15). The three do not share a currency — gore ranks bodies
# killed, zombie tennis ranks opponents felled, classic ranks games taken off the CPU — so a single
# board was really the gore mode's with the other two locked out of it. The server does not know or
# care what the numbers MEAN (see stats.BOARDS on the client); it sorts them within a mode and
# nothing crosses. Rows written before the split carry no mode and ARE gore rows, which is why 'g'
# is the default everywhere and no migration is needed.
MODES = ('g', 'z', 'c')
lock = threading.Lock()
def mode_of(r):
return r.get('m') or 'g'
def load():
try:
with open(FILE) as f:
@@ -60,7 +74,10 @@ def clean(payload):
if not (0 <= kills <= 1_000_000 and 0 <= games <= 10_000):
return None # bounds keep the sort honest and the column narrow
who = re.sub(r'[^a-z0-9]', '', str(payload.get('id', '')))[:40]
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who}
mode = str(payload.get('m', 'g'))
if mode not in MODES:
mode = 'g' # an unknown board is the default one, never a new one
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who, 'm': mode}
def public(rows):
@@ -212,8 +229,11 @@ class Handler(SimpleHTTPRequestHandler):
if path == '/ws':
return self.handle_ws()
if path == '/api/scores':
want = re.sub(r'[^gzc]', '', (self.path.split('m=')[1].split('&')[0]
if 'm=' in self.path else ''))[:1] or 'g'
with lock:
return self.reply(200, {'scores': public(load())})
return self.reply(200, {'m': want,
'scores': public([r for r in load() if mode_of(r) == want])})
# Don't hand out the source. Nothing secret lives in it — the .env is not in the image and
# the client is public JS regardless — but serving server.py and the asset-pipeline scripts
# to anyone who guesses the name is untidy, and it publishes the exact validation rules.
@@ -238,26 +258,38 @@ class Handler(SimpleHTTPRequestHandler):
return self.reply(400, {'error': 'bad score'})
with lock:
rows = load()
mine = [r for r in rows if r.get('n') == row['n']]
# A NAME BELONGS TO WHOEVER TOOK IT FIRST. The client mints a random id once and keeps
# it; the row remembers it, and a submission under someone else's name with a different
# id is refused. That is the whole of the "security" here — enough that a stranger
# cannot overwrite your row, and not one line more. Clear your browser storage and you
# lose the name, which is the honest cost of having no accounts.
if mine and mine[0].get('id') and mine[0]['id'] != row['id']:
# A NAME BELONGS TO WHOEVER TOOK IT FIRST, ACROSS ALL THREE BOARDS. The claim is on the
# NAME, not on a row: one person owns "OLIVER" everywhere, or a stranger could take it
# on the classic board while you hold it on gore. The client mints a random id once and
# keeps it; the row remembers it, and a submission under someone else's name with a
# different id is refused. That is the whole of the "security" here — enough that a
# stranger cannot overwrite your row, and not one line more. Clear your browser storage
# and you lose the name, which is the honest cost of having no accounts.
owner = next((r for r in rows if r.get('n') == row['n'] and r.get('id')), None)
if owner and owner['id'] != row['id']:
return self.reply(409, {'error': 'name taken'})
# EXACTLY ONE ROW PER NAME, their best. Without this the board is one player's session:
# every personal best posts again and a good run buries everyone else under itself.
# The client only posts on a personal best, but its idea of "best" is per-device — the
# same player on a second phone starts from zero and posts worse runs, so a weaker
# submission has to be dropped HERE rather than merely not sorted to the top.
# EXACTLY ONE ROW PER NAME PER BOARD, their best on it. Without this the board is one
# player's session: every personal best posts again and a good run buries everyone else
# under itself. The client only posts on a personal best, but its idea of "best" is
# per-device — the same player on a second phone starts from zero and posts worse runs,
# so a weaker submission has to be dropped HERE rather than merely not sorted to the top.
mine = [r for r in rows if r.get('n') == row['n'] and mode_of(r) == row['m']]
if row['k'] > max((r.get('k', 0) for r in mine), default=-1):
rows = [r for r in rows if r.get('n') != row['n']]
rows = [r for r in rows
if not (r.get('n') == row['n'] and mode_of(r) == row['m'])]
rows.append(row)
rows.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
rows = rows[:KEEP]
# sorted and trimmed PER BOARD, so a busy mode can never evict a quiet one
kept = []
for m in MODES:
rs = [r for r in rows if mode_of(r) == m]
rs.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
kept += rs[:KEEP]
rows = kept
save(rows)
return self.reply(200, {'ok': True, 'rank': rank_of(rows, row['n']), 'scores': public(rows)})
board = [r for r in rows if mode_of(r) == row['m']]
board.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
return self.reply(200, {'ok': True, 'm': row['m'],
'rank': rank_of(board, row['n']), 'scores': public(board)})
# ---- static, with the headers the nginx conf used to set ----
def end_headers(self):