diff --git a/Build/godot.sh b/Build/godot.sh
new file mode 100644
index 0000000..34eae6e
--- /dev/null
+++ b/Build/godot.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+./scavgame.x86_64 &
+GODOT_PID=$!
+
+wmctrl -r "scavgame" -b add,hidden
+
+wait "$GODOT_PID"
diff --git a/Build/index.html b/Build/index.html
new file mode 100644
index 0000000..ab91d56
--- /dev/null
+++ b/Build/index.html
@@ -0,0 +1,1243 @@
+
+
+
+
+
+ Remote Godot Game
+
+
+
+
+
+ Connecting...
+
+
+
+
+
+
+
diff --git a/Build/libwebrtc_native.linux.template_release.x86_64.so b/Build/libwebrtc_native.linux.template_release.x86_64.so
new file mode 100644
index 0000000..cc754f1
Binary files /dev/null and b/Build/libwebrtc_native.linux.template_release.x86_64.so differ
diff --git a/Build/scavgame.pck b/Build/scavgame.pck
index f7d0128..fdef250 100644
Binary files a/Build/scavgame.pck and b/Build/scavgame.pck differ
diff --git a/Build/scavgame.x86_64 b/Build/scavgame.x86_64
new file mode 100644
index 0000000..b7a063b
Binary files /dev/null and b/Build/scavgame.x86_64 differ
diff --git a/Build/server.py b/Build/server.py
new file mode 100644
index 0000000..c408256
--- /dev/null
+++ b/Build/server.py
@@ -0,0 +1,906 @@
+#!/usr/bin/env python3
+
+import asyncio
+import json
+import os
+import uuid
+from pathlib import Path
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+
+from websockets.asyncio.server import serve
+from websockets.exceptions import ConnectionClosed
+
+
+# ============================================================
+# Configuration
+# ============================================================
+
+HTTP_HOST = "0.0.0.0"
+HTTP_PORT = 8080
+
+WS_HOST = "0.0.0.0"
+WS_PORT = 8081
+
+GAME_EXECUTABLE = Path(
+ "/data/1/Projects/Scavenger-2027/Build/scavgame.x86_64"
+)
+
+GAME_DIRECTORY = GAME_EXECUTABLE.parent
+WEB_DIRECTORY = GAME_DIRECTORY
+
+MAX_SESSIONS = 4
+
+DEBUG_LOGGING = True
+
+sessions: dict[str, "GameSession"] = {}
+
+
+# ============================================================
+# Logging
+# ============================================================
+
+def log(*args):
+ print("[Server]", *args, flush=True)
+
+
+def debug(*args):
+ if DEBUG_LOGGING:
+ print("[Server][DEBUG]", *args, flush=True)
+
+
+def warning(*args):
+ print("[Server][WARNING]", *args, flush=True)
+
+
+def error(*args):
+ print("[Server][ERROR]", *args, flush=True)
+
+
+# ============================================================
+# Message helpers
+# ============================================================
+
+def normalize_sdp_message(message: dict) -> dict | None:
+ """
+ Normalizes these formats:
+
+ Browser:
+ {
+ "type": "offer",
+ "offer": {
+ "type": "offer",
+ "sdp": "..."
+ }
+ }
+
+ Godot:
+ {
+ "type": "offer",
+ "sdp": "..."
+ }
+
+ The same applies to answers.
+ """
+
+ message_type = message.get("type")
+
+ if message_type not in {"offer", "answer"}:
+ return None
+
+ nested_key = message_type
+
+ nested = message.get(nested_key)
+
+ if isinstance(nested, dict):
+ sdp = nested.get("sdp", "")
+
+ if not sdp:
+ return None
+
+ return {
+ "type": message_type,
+ "sdp": sdp
+ }
+
+ sdp = message.get("sdp", "")
+
+ if not sdp:
+ return None
+
+ return {
+ "type": message_type,
+ "sdp": sdp
+ }
+
+
+def normalize_ice_message(message: dict) -> dict | None:
+ """
+ Normalizes both:
+
+ { "type": "ice", "candidate": {...} }
+
+ and:
+
+ { "type": "candidate", "candidate": {...} }
+ """
+
+ candidate = message.get("candidate")
+
+ if not isinstance(candidate, dict):
+ return None
+
+ if not candidate.get("candidate"):
+ return None
+
+ return {
+ "type": "ice",
+ "candidate": candidate
+ }
+
+
+# ============================================================
+# Game session
+# ============================================================
+
+class GameSession:
+ def __init__(
+ self,
+ browser,
+ requested_session_id: str | None = None
+ ):
+ self.id = (
+ requested_session_id
+ if requested_session_id
+ else uuid.uuid4().hex
+ )
+
+ self.browser = browser
+ self.godot = None
+ self.process = None
+
+ self.browser_connected = True
+ self.godot_connected = False
+ self.stopping = False
+
+ self.send_lock = asyncio.Lock()
+
+ # --------------------------------------------------------
+ # Game process
+ # --------------------------------------------------------
+
+ async def start(self):
+ if not GAME_EXECUTABLE.exists():
+ raise FileNotFoundError(
+ f"Game executable not found: {GAME_EXECUTABLE}"
+ )
+
+ if not os.access(GAME_EXECUTABLE, os.X_OK):
+ raise PermissionError(
+ f"Game is not executable: {GAME_EXECUTABLE}"
+ )
+
+ env = os.environ.copy()
+
+ env["SCAV_SESSION_ID"] = self.id
+
+ # Keep Forward+ rendering.
+ env["DISPLAY"] = ":1"
+
+ # Start minimized and avoid taking focus.
+ env["SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS"] = "1"
+
+ command = [
+ str(GAME_EXECUTABLE),
+
+ "--session-id",
+ self.id,
+
+ "--debug-logging",
+
+ "--resolution",
+ "1x1",
+ ]
+
+ debug(
+ f"[{self.id}] Starting game command:",
+ command
+ )
+
+ self.process = await asyncio.create_subprocess_exec(
+ *command,
+
+ cwd=str(GAME_DIRECTORY),
+ env=env,
+
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.STDOUT,
+ )
+
+ log(
+ f"[{self.id}] Started game "
+ f"PID={self.process.pid}"
+ )
+
+ asyncio.create_task(
+ self.read_output()
+ )
+
+ async def read_output(self):
+ process = self.process
+
+ if process is None:
+ return
+
+ if process.stdout is None:
+ return
+
+ try:
+ async for line in process.stdout:
+ output = line.decode(
+ errors="replace"
+ ).rstrip()
+
+ print(
+ f"[{self.id}] {output}",
+ flush=True
+ )
+
+ except asyncio.CancelledError:
+ raise
+
+ except Exception as exc:
+ error(
+ f"[{self.id}] Output reader error:",
+ exc
+ )
+
+ async def stop(self):
+ if self.stopping:
+ return
+
+ self.stopping = True
+
+ process = self.process
+ self.process = None
+
+ if process is None:
+ return
+
+ if process.returncode is not None:
+ return
+
+ log(
+ f"[{self.id}] Stopping game "
+ f"PID={process.pid}"
+ )
+
+ try:
+ process.terminate()
+
+ await asyncio.wait_for(
+ process.wait(),
+ timeout=3
+ )
+
+ log(
+ f"[{self.id}] Game exited "
+ f"code={process.returncode}"
+ )
+
+ except asyncio.TimeoutError:
+ warning(
+ f"[{self.id}] Game did not exit; "
+ f"killing PID={process.pid}"
+ )
+
+ try:
+ process.kill()
+ await process.wait()
+ except ProcessLookupError:
+ pass
+
+ except ProcessLookupError:
+ pass
+
+ # --------------------------------------------------------
+ # WebSocket sending
+ # --------------------------------------------------------
+
+ async def send_json(
+ self,
+ websocket,
+ message: dict
+ ) -> bool:
+ if websocket is None:
+ return False
+
+ try:
+ payload = json.dumps(message)
+
+ async with self.send_lock:
+ await websocket.send(payload)
+
+ debug(
+ f"[{self.id}] Sent:",
+ message
+ )
+
+ return True
+
+ except ConnectionClosed:
+ return False
+
+ except Exception as exc:
+ error(
+ f"[{self.id}] Send error:",
+ exc
+ )
+
+ return False
+
+ async def send_to_godot(
+ self,
+ message: dict
+ ) -> bool:
+ if self.godot is None:
+ warning(
+ f"[{self.id}] Cannot send to Godot; "
+ f"Godot is not connected"
+ )
+
+ return False
+
+ success = await self.send_json(
+ self.godot,
+ message
+ )
+
+ if not success:
+ self.godot = None
+ self.godot_connected = False
+
+ return success
+
+ async def send_to_browser(
+ self,
+ message: dict
+ ) -> bool:
+ if self.browser is None:
+ warning(
+ f"[{self.id}] Cannot send to browser; "
+ f"browser is not connected"
+ )
+
+ return False
+
+ success = await self.send_json(
+ self.browser,
+ message
+ )
+
+ if not success:
+ self.browser = None
+ self.browser_connected = False
+
+ return success
+
+
+# ============================================================
+# HTTP server
+# ============================================================
+
+class HTTPHandler(SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(
+ *args,
+ directory=str(WEB_DIRECTORY),
+ **kwargs
+ )
+
+ def log_message(self, format_string, *args):
+ print(
+ "[HTTP] " + format_string % args,
+ flush=True
+ )
+
+
+def start_http_server():
+ server = ThreadingHTTPServer(
+ (HTTP_HOST, HTTP_PORT),
+ HTTPHandler
+ )
+
+ log(
+ f"HTTP server listening on "
+ f"http://{HTTP_HOST}:{HTTP_PORT}"
+ )
+
+ try:
+ server.serve_forever()
+ finally:
+ server.server_close()
+
+
+# ============================================================
+# Browser connection
+# ============================================================
+
+async def handle_browser(
+ websocket,
+ requested_session_id: str | None = None
+):
+ if len(sessions) >= MAX_SESSIONS:
+ await websocket.send(json.dumps({
+ "type": "error",
+ "message": "Server is full"
+ }))
+
+ await websocket.close()
+ return
+
+ session = GameSession(
+ websocket,
+ requested_session_id
+ )
+
+ if session.id in sessions:
+ await websocket.send(json.dumps({
+ "type": "error",
+ "message": "Session ID is already in use"
+ }))
+
+ await websocket.close()
+ return
+
+ sessions[session.id] = session
+
+ log(
+ f"[{session.id}] Browser connected"
+ )
+
+ try:
+ await session.send_to_browser({
+ "type": "session_created",
+ "session_id": session.id
+ })
+
+ await session.start()
+
+ # Wait for Godot to connect.
+ for _ in range(100):
+ if session.godot is not None:
+ break
+
+ await asyncio.sleep(0.1)
+
+ if session.godot is None:
+ warning(
+ f"[{session.id}] Godot did not connect"
+ )
+
+ await session.send_to_browser({
+ "type": "error",
+ "message": "Godot did not connect"
+ })
+
+ return
+
+ await session.send_to_godot({
+ "type": "browser_connected"
+ })
+
+ await session.send_to_browser({
+ "type": "godot_connected"
+ })
+
+ log(
+ f"[{session.id}] Browser and Godot paired"
+ )
+
+ async for raw in websocket:
+ try:
+ message = json.loads(raw)
+
+ except json.JSONDecodeError:
+ warning(
+ f"[{session.id}] Invalid browser JSON:",
+ raw
+ )
+
+ continue
+
+ if not isinstance(message, dict):
+ warning(
+ f"[{session.id}] Browser message "
+ f"is not an object"
+ )
+
+ continue
+
+ debug(
+ f"[{session.id}] Browser message:",
+ message
+ )
+
+ message_type = message.get("type")
+
+ # ------------------------------------------------
+ # WebRTC offer
+ # ------------------------------------------------
+
+ if message_type == "offer":
+ normalized = normalize_sdp_message(
+ message
+ )
+
+ if normalized is None:
+ warning(
+ f"[{session.id}] Invalid browser offer"
+ )
+
+ continue
+
+ await session.send_to_godot(
+ normalized
+ )
+
+ # ------------------------------------------------
+ # WebRTC ICE
+ # ------------------------------------------------
+
+ elif message_type in {
+ "ice",
+ "candidate"
+ }:
+ normalized = normalize_ice_message(
+ message
+ )
+
+ if normalized is None:
+ warning(
+ f"[{session.id}] Invalid browser ICE"
+ )
+
+ continue
+
+ await session.send_to_godot(
+ normalized
+ )
+
+ else:
+ debug(
+ f"[{session.id}] Ignoring browser message:",
+ message_type
+ )
+
+ except ConnectionClosed:
+ log(
+ f"[{session.id}] Browser WebSocket closed"
+ )
+
+ except Exception as exc:
+ error(
+ f"[{session.id}] Browser error:",
+ exc
+ )
+
+ finally:
+ log(
+ f"[{session.id}] Browser disconnected"
+ )
+
+ session.browser_connected = False
+
+ if session.godot is not None:
+ await session.send_to_godot({
+ "type": "browser_disconnected"
+ })
+
+ await asyncio.sleep(0.2)
+
+ try:
+ await session.godot.close()
+ except Exception:
+ pass
+
+ session.godot = None
+ session.godot_connected = False
+
+ await session.stop()
+
+ sessions.pop(
+ session.id,
+ None
+ )
+
+ log(
+ f"[{session.id}] Session removed"
+ )
+
+
+# ============================================================
+# Godot connection
+# ============================================================
+
+async def handle_godot(
+ websocket,
+ session_id: str
+):
+ session = sessions.get(session_id)
+
+ if session is None:
+ warning(
+ f"[{session_id}] Godot attempted to connect "
+ f"to unknown session"
+ )
+
+ await websocket.send(json.dumps({
+ "type": "error",
+ "message": "Unknown session"
+ }))
+
+ await websocket.close()
+ return
+
+ if session.godot is not None:
+ warning(
+ f"[{session.id}] Replacing existing Godot connection"
+ )
+
+ try:
+ await session.godot.close()
+ except Exception:
+ pass
+
+ session.godot = websocket
+ session.godot_connected = True
+
+ log(
+ f"[{session.id}] Godot connected"
+ )
+
+ try:
+ await session.send_to_godot({
+ "type": "registered",
+ "session_id": session.id
+ })
+
+ if session.browser_connected:
+ await session.send_to_godot({
+ "type": "browser_connected"
+ })
+
+ await session.send_to_browser({
+ "type": "godot_connected"
+ })
+
+ async for raw in websocket:
+ try:
+ message = json.loads(raw)
+
+ except json.JSONDecodeError:
+ warning(
+ f"[{session.id}] Invalid Godot JSON:",
+ raw
+ )
+
+ continue
+
+ if not isinstance(message, dict):
+ warning(
+ f"[{session.id}] Godot message "
+ f"is not an object"
+ )
+
+ continue
+
+ debug(
+ f"[{session.id}] Godot message:",
+ message
+ )
+
+ message_type = message.get("type")
+
+ # ------------------------------------------------
+ # WebRTC answer
+ # ------------------------------------------------
+
+ if message_type == "answer":
+ normalized = normalize_sdp_message(
+ message
+ )
+
+ if normalized is None:
+ warning(
+ f"[{session.id}] Invalid Godot answer"
+ )
+
+ continue
+
+ await session.send_to_browser({
+ "type": "answer",
+ "answer": {
+ "type": "answer",
+ "sdp": normalized["sdp"]
+ }
+ })
+
+ # ------------------------------------------------
+ # WebRTC ICE
+ # ------------------------------------------------
+
+ elif message_type in {
+ "ice",
+ "candidate"
+ }:
+ normalized = normalize_ice_message(
+ message
+ )
+
+ if normalized is None:
+ warning(
+ f"[{session.id}] Invalid Godot ICE"
+ )
+
+ continue
+
+ await session.send_to_browser(
+ normalized
+ )
+
+ else:
+ debug(
+ f"[{session.id}] Ignoring Godot message:",
+ message_type
+ )
+
+ except ConnectionClosed:
+ log(
+ f"[{session.id}] Godot WebSocket closed"
+ )
+
+ except Exception as exc:
+ error(
+ f"[{session.id}] Godot error:",
+ exc
+ )
+
+ finally:
+ log(
+ f"[{session.id}] Godot disconnected"
+ )
+
+ if session.godot is websocket:
+ session.godot = None
+ session.godot_connected = False
+
+ if session.browser is not None:
+ await session.send_to_browser({
+ "type": "godot_disconnected"
+ })
+
+
+# ============================================================
+# WebSocket routing
+# ============================================================
+
+async def websocket_handler(websocket):
+ try:
+ raw = await websocket.recv()
+
+ message = json.loads(raw)
+
+ if not isinstance(message, dict):
+ await websocket.close()
+ return
+
+ role = message.get("role")
+
+ debug(
+ "Initial WebSocket registration:",
+ message
+ )
+
+ if role == "browser":
+ requested_session_id = message.get(
+ "session_id"
+ )
+
+ await handle_browser(
+ websocket,
+ requested_session_id
+ )
+
+ elif role == "godot":
+ session_id = message.get(
+ "session_id"
+ )
+
+ if not session_id:
+ await websocket.send(json.dumps({
+ "type": "error",
+ "message": "Missing session_id"
+ }))
+
+ await websocket.close()
+ return
+
+ await handle_godot(
+ websocket,
+ session_id
+ )
+
+ else:
+ await websocket.send(json.dumps({
+ "type": "error",
+ "message": "Invalid role"
+ }))
+
+ await websocket.close()
+
+ except ConnectionClosed:
+ pass
+
+ except json.JSONDecodeError:
+ warning(
+ "Received invalid initial WebSocket JSON"
+ )
+
+ try:
+ await websocket.close()
+ except Exception:
+ pass
+
+ except Exception as exc:
+ error(
+ "WebSocket handler error:",
+ exc
+ )
+
+
+# ============================================================
+# WebSocket server
+# ============================================================
+
+async def websocket_server():
+ log(
+ f"WebSocket server listening on "
+ f"ws://{WS_HOST}:{WS_PORT}"
+ )
+
+ async with serve(
+ websocket_handler,
+ WS_HOST,
+ WS_PORT
+ ):
+ await asyncio.Future()
+
+
+# ============================================================
+# Main
+# ============================================================
+
+async def main():
+ http_task = asyncio.to_thread(
+ start_http_server
+ )
+
+ websocket_task = asyncio.create_task(
+ websocket_server()
+ )
+
+ await asyncio.gather(
+ http_task,
+ websocket_task
+ )
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+
+ except KeyboardInterrupt:
+ print(
+ "\nServer stopped",
+ flush=True
+ )