Transport: integrate aioquic QUIC datagram server skeleton (QuicWebTransportServer) and QUIC mode in run.py

- New server/quic_transport.py using aioquic to accept QUIC connections and datagrams
- run.py: QUIC mode when QUIC_CERT/QUIC_KEY provided; else in-memory
- requirements.txt: aioquic + cryptography
This commit is contained in:
Vladyslav Doloman
2025-10-07 20:53:24 +03:00
parent 352da0ef54
commit e79c523034
10 changed files with 108 additions and 32 deletions

View File

@@ -1,27 +0,0 @@
This project will be a network mutiplayer Snake game.
python 3 server and a web client that uses webtransport datagrams.
the logic of the game: When the snake hits something, it doesn't die, the head stays in place, but the tail shortens by 1 every tick the head is turned in the direction of the obstacle. The player can change the direction the head is turned and the snake continues to move in new direction. The snake can not be shorter than its head, so the head always lives, even when the tail is all gone.
consider the possibility of stucking on anothers player snake (head or tail)? when the other player's snake have passed by and no more considered the obstacle, the current player's snake should continue moving.
self-collision is not permanent, because the tail keeps shrinking, there will be a moment, when the segment of a tail that was an obstacle will cease to exist
other snake collision can be auto-cleared when the other snake is moved away or shrinks to the point when it is no longer an obstacle
if a player is disconnected - his snake and score dissapears from the game, so there is no game-over state. the server runs constantly, without "rounds" of gameplay. new players connect directly into the game. There is no end game to detect winner or loser, there is continuous gameplay and in each moment there is a length of each snake. The longest snake is the "winner" at each moment (considering it can shorten or be outrunned by another player and lose its winning position).
keep the snake color the same for the whole duration of client connection
instead of a score based on eaten apples display the snakes current lengths
when only the head is left - it can turn 180 degrees as well as 90
make a small input buffer to buffer 3 direction changes for next ticks. if the new direction the user is pressing is directly opposite to the last in the buffer - replace the last in the buffer (do not add it as a new step).
instead of ignoring overflowinf inputs - replace the last one.
do not add to the buffer repeating inputs. before calculating next position when consuming 1 direction from buffer check if it is 180 turn when snake length>1, if so - ignore this input and consume the next one.
when connected to server - show the current gameplay on the background with the text overlay "press space to join"
when 0 players left - populate the field with 3 apples
field size 60 by 40 (by default). allow room in the protocol it to be changed between 3x3 up to 255x255
when using webtransport datagrams (UDP) for the game protocol, add packet number into the message to ignore late packets. make possible to wrap numbering from the beginnig in a case of integer overflow. use compression for the data in packets, so the full field update can be transmitted as one UDP datagram (typically up to 1500 bytes)
in UDP (webtransport datagrams) Protocol Design - allow room for lost packets before/after wrapping;
make room for up to 32 simultaneous players with different colors; limit player name length to 16 characters
if UDP packet size exceeds 1280 bytes - split the update in several parts that do not exceed this size by updating different snakes info in different independant packets, so if one of them is lost - some of the information still reaches the recipient. If a snake is so long that its update doesn't fit into one packet by itself - then find a way to split it into several updates, preferably of the similar size (split the snake into the equal size parts)

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
aioquic>=1.2.0
cryptography>=41.0.0

36
run.py
View File

@@ -1,10 +1,36 @@
from server.server import main
import asyncio
import os
from server.server import GameServer
from server.config import ServerConfig
from server.transport import InMemoryTransport
async def main():
from server.server import GameServer
from server.transport import InMemoryTransport
cfg = ServerConfig()
server = GameServer(transport=InMemoryTransport(lambda d, p: server.on_datagram(d, p)), config=cfg)
await asyncio.gather(server.transport.run(), server.tick_loop())
if __name__ == "__main__":
import asyncio
try:
asyncio.run(main())
# Optional QUIC mode if env vars are provided
cert = os.environ.get("QUIC_CERT")
key = os.environ.get("QUIC_KEY")
host = os.environ.get("QUIC_HOST", "0.0.0.0")
port = int(os.environ.get("QUIC_PORT", "4433"))
if cert and key:
from server.quic_transport import QuicWebTransportServer
from server.server import GameServer
cfg = ServerConfig()
async def start_quic():
server = GameServer(transport=QuicWebTransportServer(host, port, cert, key, lambda d, p: server.on_datagram(d, p)), config=cfg)
await asyncio.gather(server.transport.run(), server.tick_loop())
asyncio.run(start_quic())
else:
asyncio.run(main())
except KeyboardInterrupt:
pass

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

75
server/quic_transport.py Normal file
View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Optional
from .transport import DatagramServerTransport, OnDatagram, TransportPeer
try:
from aioquic.asyncio import QuicConnectionProtocol, serve
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import DatagramFrameReceived, ProtocolNegotiated
except Exception: # pragma: no cover - optional dependency not installed in skeleton
QuicConnectionProtocol = object # type: ignore
QuicConfiguration = object # type: ignore
serve = None # type: ignore
DatagramFrameReceived = object # type: ignore
ProtocolNegotiated = object # type: ignore
class GameQuicProtocol(QuicConnectionProtocol): # type: ignore[misc]
def __init__(self, *args, on_datagram: OnDatagram, peers: Dict[int, "GameQuicProtocol"], **kwargs):
super().__init__(*args, **kwargs)
self._on_datagram = on_datagram
self._peers = peers
self._peer_id: Optional[int] = None
def quic_event_received(self, event) -> None: # type: ignore[override]
if isinstance(event, ProtocolNegotiated):
# Register by connection id
self._peer_id = int(self._quic.connection_id) # type: ignore[attr-defined]
self._peers[self._peer_id] = self
elif isinstance(event, DatagramFrameReceived):
# Schedule async callback
if self._peer_id is None:
return
peer = TransportPeer(addr=self)
asyncio.ensure_future(self._on_datagram(bytes(event.data), peer))
async def send_datagram(self, data: bytes) -> None:
self._quic.send_datagram_frame(data) # type: ignore[attr-defined]
await self._loop.run_in_executor(None, self.transmit) # type: ignore[attr-defined]
class QuicWebTransportServer(DatagramServerTransport):
def __init__(self, host: str, port: int, certfile: str, keyfile: str, on_datagram: OnDatagram):
if serve is None:
raise RuntimeError("aioquic is not installed. Please `pip install aioquic`.")
self.host = host
self.port = port
self.certfile = certfile
self.keyfile = keyfile
self._on_datagram = on_datagram
self._server = None
self._peers: Dict[int, GameQuicProtocol] = {}
async def send(self, data: bytes, peer: TransportPeer) -> None:
proto = peer.addr # expected GameQuicProtocol
if isinstance(proto, GameQuicProtocol):
await proto.send_datagram(data)
async def run(self) -> None:
configuration = QuicConfiguration(is_client=False, alpn_protocols=["h3"])
configuration.load_cert_chain(self.certfile, self.keyfile)
async def _create_protocol(*args, **kwargs):
return GameQuicProtocol(*args, on_datagram=self._on_datagram, peers=self._peers, **kwargs)
self._server = await serve(self.host, self.port, configuration=configuration, create_protocol=_create_protocol)
try:
await self._server.wait_closed()
finally:
self._server.close()