Two phones, one match: the shot is the message, the receiver is the judge

VS A FRIEND on the mode screen (host a code, join a code), a ~110-line
RFC6455 relay in server.py that holds no game state, and src/net.js: each
phone plays itself near-side and the opponent's court is this one rotated
half a turn about the net. The phone a ball flies toward makes every call
on it; the striker's copy is picture. Six tests pin the rule from both
seats. No reconnect yet — a dropped radio is a card and a reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 17:36:02 +02:00
1 parent 108ed695eb
commit f302354bb2
11 files changed
+493 -21

No files matched your search

+111 -1
View File
@@ -14,7 +14,7 @@ board is a wall for writing your name on, and the client is public JS that could
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 json, os, re, threading, time
import base64, hashlib, json, os, random, re, threading, time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -73,6 +73,48 @@ 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)
@@ -90,8 +132,76 @@ class Handler(SimpleHTTPRequestHandler):
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
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
ws_send(me['peer'], '{"t":"start","side":"A"}')
ws_send(me, '{"t":"start","side":"B"}')
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())})