Files
splatter-tennis/server.py
T
wuwiandClaude Opus 5 9cc3ac54b3 The dead club doubles its staff, and the draw is made where you can see it
Six new opponent families (head coach, stringer, strawberry seller, chair
umpire, spectator, roller man) through the famroll/zsheet route, placed by
hierarchy into an 18-rung ladder; the champion steps up behind them.

THE DRAW: picking the tournament now rolls its own bracket in front of you —
only the final was ever settled — and what lands is what walks on.

Netplay, off Oliver's cellular report: the mid-flight early-decide was missing
its !G.net gate, so the striker judged its own outbound flight and the two
scoreboards drifted a point apart into a court nobody could serve on. The
host's court now rides the room, its scoreboard rides the pretoss stream, and
a called ball rolls to its own halt instead of freezing where the call caught it.

Plus the night court, and somebody in the stand is taking pictures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:49:19 +02:00

291 lines
14 KiB
Python

#!/usr/bin/env python3
"""The whole backend: static files plus a two-route high-score API, on the standard library.
It replaced the nginx image because the leaderboard needs ONE writable process and nginx is not
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"}
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
board is a wall for writing your name on, and the client is public JS that could be made to post
anything regardless. What IS enforced is that nothing here can be made to crash, hang or grow
without bound by a hostile request — see clean() and the caps below.
"""
import base64, hashlib, json, os, random, re, threading, time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.environ.get('SPLAT_DATA') or ('/data' if os.path.isdir('/data') else HERE)
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
MAX_BODY = 2048 # a submission is ~60 bytes. Anything larger is not one.
NAME_MAX = 12
lock = threading.Lock()
def load():
try:
with open(FILE) as f:
rows = json.load(f)
return rows if isinstance(rows, list) else []
except (OSError, ValueError):
return [] # missing, empty or corrupt — an empty board, never a 500
def save(rows):
os.makedirs(DATA, exist_ok=True)
tmp = FILE + '.tmp' # write-then-rename: a torn file here is a wiped leaderboard
with open(tmp, 'w') as f:
json.dump(rows, f)
os.replace(tmp, FILE)
def clean(payload):
"""A submission, or None. Every field is re-derived — nothing from the request reaches the file
unexamined, because that file is read back and rendered into the page."""
if not isinstance(payload, dict):
return None
name = re.sub(r'[^A-Z0-9 ._-]', '', str(payload.get('n', '')).upper()).strip()[:NAME_MAX]
if not name:
return None
try:
kills, games = int(payload['k']), int(payload['g'])
except (KeyError, TypeError, ValueError):
return None
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}
def public(rows):
"""The board as anyone may see it. `id` is the ONLY thing standing between a player and someone
else typing their name, so it must never leave the server."""
return [{k: v for k, v in r.items() if k != 'id'} for r in rows[:100]]
def rank_of(rows, name):
return next((i + 1 for i, r in enumerate(rows) if r.get('n') == name), len(rows))
# ---- the multiplayer relay: RFC6455 on the standard library, because that is all a dumb relay
# needs. The netcode's sync unit is THE SHOT (see PROJECT.md, LATER item 7), so this server holds
# no game state at all: it mints a 4-letter room code, pairs exactly two sockets, and forwards
# text frames verbatim between them. The clients judge everything; a hostile client can only cheat
# its friend, which is the agreed trust model for friend codes. Caps mirror the score API's stance:
# nothing here may crash, hang or grow without bound on a hostile request.
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
WS_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789' # read aloud between phones: no I/L/O/U/0/1
WS_MAX = 2048 # a shot event is ~120 bytes; anything larger is not one
ROOMS = {} # code -> [host_conn] while waiting; deleted on pairing
ws_lock = threading.Lock()
def ws_send(conn, text):
data = text if isinstance(text, bytes) else text.encode()
head = bytes([0x81, len(data)]) if len(data) < 126 else bytes([0x81, 126]) + len(data).to_bytes(2, 'big')
try:
with conn['lock']: # relay thread and own thread share this socket
conn['sock'].sendall(head + data)
except OSError:
pass # a dead peer is handled by its own recv loop
def ws_read(rf):
"""One frame -> (opcode, payload) or (None, b'') on EOF/oversize. Client frames are masked."""
h = rf.read(2)
if len(h) < 2:
return None, b''
op, n = h[0] & 0x0f, h[1] & 0x7f
if n == 126:
n = int.from_bytes(rf.read(2), 'big')
elif n == 127:
n = int.from_bytes(rf.read(8), 'big')
if n > WS_MAX:
return None, b''
mask = rf.read(4) if h[1] & 0x80 else None
data = rf.read(n)
if mask:
data = bytes(b ^ mask[i & 3] for i, b in enumerate(data))
return op, data
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=HERE, **kw)
def log_message(self, *a):
pass # one line per sprite request is not a log, it is noise
# ---- the API ----
def reply(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(body)
# ---- the relay endpoint ----
def handle_ws(self):
key = self.headers.get('Sec-WebSocket-Key')
if not key or (self.headers.get('Upgrade') or '').lower() != 'websocket':
return self.send_error(400)
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
self.connection.sendall(b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n'
b'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + accept.encode() + b'\r\n\r\n')
self.close_connection = True
# a phone that vanishes without FIN must not hold a thread and a room forever — the client
# heartbeats every 20 s, so 75 s of silence is a dead radio, not a slow one
self.connection.settimeout(75)
me = {'sock': self.connection, 'lock': threading.Lock(), 'peer': None, 'room': None}
want = re.sub(r'[^A-Z0-9]', '', (self.path.split('room=')[1] if 'room=' in self.path else '').upper())[:8]
with ws_lock:
if want: # joining a friend
room = ROOMS.pop(want, None)
if not room:
ws_send(me, '{"t":"gone"}')
return
host = room[0]
me['peer'] = host
host['peer'] = me # the code is single-use: popped above, never re-joinable
else: # hosting: mint a code nobody is holding
code = ''.join(random.choice(WS_ALPHABET) for _ in range(4))
while code in ROOMS:
code = ''.join(random.choice(WS_ALPHABET) for _ in range(4))
ROOMS[code] = [me]
me['room'] = code
# the host's decisions ride the room: classic tennis or the death match, and WHICH
# COURT. The start message carries both to both phones, so the joiner is never
# asked — and never ends up playing the same rally on a different surface.
me['death'] = 'death=1' in self.path
me['court'] = re.sub(r'[^a-z]', '', self.path.split('court=')[1].split('&')[0]
if 'court=' in self.path else '')[:12]
if len(ROOMS) > 200: # a lobby is two people; 200 stale rooms is a flood
ROOMS.pop(next(iter(ROOMS)))
if me['peer']: # paired: the host serves first, on both scoreboards
host = me['peer'] # ...and the host's picks are what both phones get
for who, side in ((host, 'A'), (me, 'B')):
ws_send(who, json.dumps({'t': 'start', 'side': side,
'death': bool(host.get('death')),
'court': host.get('court') or ''}))
else:
ws_send(me, json.dumps({'t': 'room', 'code': me['room']}))
try:
while True:
try:
op, data = ws_read(self.rfile)
except OSError: # timeout or reset: same as hanging up
break
if op is None or op == 8:
break
if op == 9: # protocol ping -> pong, payload echoed
try:
with me['lock']:
me['sock'].sendall(bytes([0x8A, len(data)]) + data)
except OSError:
pass
continue
if op != 1 or data == b'{"t":"hb"}':
continue # the heartbeat is for the socket, not the friend
if me['peer']:
ws_send(me['peer'], data) # the whole job: forward the frame verbatim
finally:
with ws_lock:
if me['room'] and ROOMS.get(me['room']) and ROOMS[me['room']][0] is me:
del ROOMS[me['room']] # nobody ever came
peer = me['peer']
me['peer'] = None
if peer:
peer['peer'] = None
if peer:
ws_send(peer, '{"t":"bye"}')
def do_GET(self):
path = self.path.split('?')[0]
if path == '/ws':
return self.handle_ws()
if path == '/api/scores':
with lock:
return self.reply(200, {'scores': public(load())})
# 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.
if path.endswith(('.py', '.pyc')) or '__pycache__' in path:
return self.send_error(404)
return super().do_GET()
def do_POST(self):
if self.path.split('?')[0] != '/api/scores':
return self.send_error(404)
try:
n = int(self.headers.get('Content-Length') or 0)
except ValueError:
return self.reply(400, {'error': 'bad length'})
if not 0 < n <= MAX_BODY:
return self.reply(400, {'error': 'bad length'})
try:
row = clean(json.loads(self.rfile.read(n)))
except ValueError:
row = None
if not row:
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']:
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.
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.append(row)
rows.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
rows = rows[:KEEP]
save(rows)
return self.reply(200, {'ok': True, 'rank': rank_of(rows, row['n']), 'scores': public(rows)})
# ---- static, with the headers the nginx conf used to set ----
def end_headers(self):
if self.path.startswith('/api/'):
pass # reply() sets its own no-store
elif self.path.startswith('/assets/'):
# THE COLD-START FIX. no-cache here meant ~230 sprites, three fonts and the audio were
# REVALIDATED on every launch — 230 round trips before the game had a full set of
# frames, on a phone, sometimes over a connection that dropped a few. That is why the
# fonts arrived late and the stats screen showed gaps. A ?v= URL can be cached forever
# because changing the file changes the URL (see SPRV in render.js); anything
# unversioned gets a day, so a replaced logo or icon still lands without a year of
# staleness. The HTML and JS stay no-cache — that is what makes a deploy visible.
self.send_header('Cache-Control', 'public, max-age=31536000, immutable'
if '?v=' in self.path else 'public, max-age=86400')
else:
self.send_header('Cache-Control', 'no-cache, must-revalidate')
super().end_headers()
def guess_type(self, path):
# Chrome refuses to install a homescreen app whose manifest arrives as octet-stream, and
# mimetypes has no .webmanifest entry.
if path.endswith('.webmanifest'):
return 'application/manifest+json'
return super().guess_type(path)
if __name__ == '__main__':
print(f'splatter-tennis on :{PORT}, scores in {FILE}', flush=True)
ThreadingHTTPServer(('', PORT), Handler).serve_forever()