diff --git a/server/protocol.py b/server/protocol.py index 09e3e67..0561157 100644 --- a/server/protocol.py +++ b/server/protocol.py @@ -297,3 +297,80 @@ def build_state_full( body.append(ax & 0xFF) body.append(ay & 0xFF) return header + bytes(body) + + +# --- State Delta --- + +@dataclass +class SnakeDelta: + snake_id: int + head_moved: bool + tail_removed: bool + grew: bool + blocked: bool + new_head_x: int = 0 + new_head_y: int = 0 + direction: Direction = Direction.RIGHT + + +def build_state_delta_body( + *, update_id: int, changes: Sequence[SnakeDelta], apples_added: Sequence[Tuple[int, int]], apples_removed: Sequence[Tuple[int, int]] +) -> bytes: + body = bytearray() + body.extend(int(update_id & 0xFFFF).to_bytes(2, "big")) + # snakes + body.extend(quic_varint_encode(len(changes))) + for ch in changes: + body.append(ch.snake_id & 0xFF) + flags = ( + (1 if ch.head_moved else 0) + | ((1 if ch.tail_removed else 0) << 1) + | ((1 if ch.grew else 0) << 2) + | ((1 if ch.blocked else 0) << 3) + ) + body.append(flags & 0xFF) + body.append(int(ch.direction) & 0x03) + if ch.head_moved: + body.append(ch.new_head_x & 0xFF) + body.append(ch.new_head_y & 0xFF) + # apples added + body.extend(quic_varint_encode(len(apples_added))) + for ax, ay in apples_added: + body.append(ax & 0xFF) + body.append(ay & 0xFF) + # apples removed + body.extend(quic_varint_encode(len(apples_removed))) + for rx, ry in apples_removed: + body.append(rx & 0xFF) + body.append(ry & 0xFF) + return bytes(body) + + +def build_state_delta( + *, version: int, seq: int, tick: int, update_id: int, changes: Sequence[SnakeDelta], apples_added: Sequence[Tuple[int, int]], apples_removed: Sequence[Tuple[int, int]] +) -> bytes: + header = pack_header(version, PacketType.STATE_DELTA, 0, seq, tick & 0xFFFF) + body = build_state_delta_body(update_id=update_id, changes=changes, apples_added=apples_added, apples_removed=apples_removed) + return header + body + + +def build_part( + *, + version: int, + seq: int, + tick: int, + update_id: int, + part_index: int, + parts_total: int, + inner_type: PacketType, + chunk_payload: bytes, +) -> bytes: + header = pack_header(version, PacketType.PART, 0, seq, tick & 0xFFFF) + body = bytearray() + body.extend(int(update_id & 0xFFFF).to_bytes(2, "big")) + body.append(part_index & 0xFF) + body.append(parts_total & 0xFF) + body.append(int(inner_type) & 0xFF) + # include the chunk payload bytes + body.extend(chunk_payload) + return header + bytes(body) diff --git a/server/server.py b/server/server.py index df8ea74..b37cc79 100644 --- a/server/server.py +++ b/server/server.py @@ -16,9 +16,10 @@ from .protocol import ( build_config_update, build_input_broadcast, build_state_full, - BodyTLV, - build_state_full, - quic_varint_encode, + build_state_delta, + build_state_delta_body, + build_part, + SnakeDelta, pack_header, parse_input, parse_join, @@ -32,11 +33,16 @@ class ServerRuntime: state: GameState seq: int = 0 version: int = 1 + update_id: int = 0 def next_seq(self) -> int: self.seq = (self.seq + 1) & 0xFFFF return self.seq + def next_update_id(self) -> int: + self.update_id = (self.update_id + 1) & 0xFFFF + return self.update_id + class GameServer: def __init__(self, transport: DatagramServerTransport, config: ServerConfig): @@ -157,6 +163,8 @@ class GameServer: st = self.runtime.state cfg = self.runtime.config apples_eaten: List[Coord] = [] + apples_before = set(st.apples) + changes: List[SnakeDelta] = [] # Prepare snapshot of tails to allow moving into own tail when it vacates tails: Dict[int, Coord] = {} for sid, s in st.snakes.items(): @@ -187,6 +195,18 @@ class GameServer: if s.length > 1: tx, ty = s.body.pop() st.occupancy.pop((tx, ty), None) + changes.append( + SnakeDelta( + snake_id=sid, + head_moved=False, + tail_removed=(s.length > 1), + grew=False, + blocked=True, + new_head_x=hx, + new_head_y=hy, + direction=s.direction, + ) + ) continue # Move or grow s.blocked = False @@ -198,40 +218,98 @@ class GameServer: # eat apple; no tail removal this tick st.apples.remove((nx, ny)) apples_eaten.append((nx, ny)) + changes.append( + SnakeDelta( + snake_id=sid, + head_moved=True, + tail_removed=False, + grew=True, + blocked=False, + new_head_x=nx, + new_head_y=ny, + direction=s.direction, + ) + ) else: # normal move: remove tail (unless length==0) tx, ty = s.body.pop() if (tx, ty) in st.occupancy: st.occupancy.pop((tx, ty), None) + changes.append( + SnakeDelta( + snake_id=sid, + head_moved=True, + tail_removed=True, + grew=False, + blocked=False, + new_head_x=nx, + new_head_y=ny, + direction=s.direction, + ) + ) # Replenish apples to target self._ensure_apples() + apples_after = set(st.apples) + apples_added = sorted(list(apples_after - apples_before)) + apples_removed = sorted(list(apples_before - apples_after)) # Broadcast a basic delta (currently full snakes + apples as delta) - snakes_dirs: List[Tuple[int, int, int, int, List[Direction]]] = [] - for s in st.snakes.values(): - dirs: List[Direction] = [] - coords = list(s.body) - for i in range(len(coords) - 1): - x0, y0 = coords[i] - x1, y1 = coords[i + 1] - if x1 == x0 and y1 == y0 - 1: - dirs.append(Direction.UP) - elif x1 == x0 + 1 and y1 == y0: - dirs.append(Direction.RIGHT) - elif x1 == x0 and y1 == y0 + 1: - dirs.append(Direction.DOWN) - elif x1 == x0 - 1 and y1 == y0: - dirs.append(Direction.LEFT) - hx, hy = coords[0] - snakes_dirs.append((s.snake_id, s.length, hx, hy, dirs)) - delta = build_state_full( - version=self.runtime.version, - seq=self.runtime.next_seq(), - tick=st.tick, - snakes=snakes_dirs, - apples=st.apples, + update_id = self.runtime.next_update_id() + # Serialize full delta body once, then partition if large + full_body = build_state_delta_body( + update_id=update_id, + changes=changes, + apples_added=apples_added, + apples_removed=apples_removed, ) - for session in list(self.sessions.values()): - await self.transport.send(delta, TransportPeer(session.peer)) + MTU = 1200 # soft limit for payload to avoid fragmentation + if len(full_body) <= MTU: + packet = pack_header(self.runtime.version, PacketType.STATE_DELTA, 0, self.runtime.next_seq(), st.tick & 0xFFFF) + full_body + for session in list(self.sessions.values()): + await self.transport.send(packet, TransportPeer(session.peer)) + else: + # Partition by splitting snake changes across parts; include apples only in the first part + parts: List[bytes] = [] + remaining = list(changes) + idx = 0 + part_index = 0 + while remaining: + chunk: List[SnakeDelta] = [] + # greedy pack until size would exceed MTU + # start with apples only for first part + add_ap = apples_added if part_index == 0 else [] + rem_ap = apples_removed if part_index == 0 else [] + # Try adding changes one by one + for i, ch in enumerate(remaining): + tmp_body = build_state_delta_body(update_id=update_id, changes=chunk + [ch], apples_added=add_ap, apples_removed=rem_ap) + if len(tmp_body) > MTU and chunk: + break + if len(tmp_body) > MTU: + # even a single change + apples doesn't fit; force single + chunk.append(ch) + i += 1 + break + chunk.append(ch) + # Remove chunked items + remaining = remaining[len(chunk) :] + # Build chunk body + chunk_body = build_state_delta_body(update_id=update_id, changes=chunk, apples_added=add_ap, apples_removed=rem_ap) + parts.append(chunk_body) + part_index += 1 + # Emit PART packets + total = len(parts) + for i, body in enumerate(parts): + pkt = build_part( + version=self.runtime.version, + seq=self.runtime.next_seq(), + tick=st.tick & 0xFFFF, + update_id=update_id, + part_index=i, + parts_total=total, + inner_type=PacketType.STATE_DELTA, + chunk_payload=body, + ) + for session in list(self.sessions.values()): + await self.transport.send(pkt, TransportPeer(session.peer)) # --- Join / Spawn ---