feat: initialize interactive live-stream game backend MVP

- Add FastAPI backend with WebSocket support
- Implement ConnectionManager for client connections
- Create GameEngine with async game loop (2s tick)
- Add RuleBasedAgent for keyword-based responses
- Define Pydantic schemas for GameEvent protocol
- Create debug frontend dashboard for testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2025-12-30 14:58:38 +08:00
commit 714b5824ba
10 changed files with 834 additions and 0 deletions

6
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
The Island - Interactive Live-Stream Game Backend
A commercial-grade backend for real-time game interactions.
"""
__version__ = "0.1.0"

82
backend/app/agents.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Agent classes for processing game inputs and generating responses.
Provides base abstraction and rule-based implementation.
"""
from abc import ABC, abstractmethod
import logging
logger = logging.getLogger(__name__)
class BaseAgent(ABC):
"""
Abstract base class for all game agents.
Agents process input text and generate appropriate responses.
Subclasses must implement the process_input method.
"""
def __init__(self, name: str = "BaseAgent") -> None:
"""
Initialize the agent.
Args:
name: Display name for the agent
"""
self.name = name
@abstractmethod
def process_input(self, text: str) -> str:
"""
Process input text and return a response.
Args:
text: The input text to process
Returns:
The agent's response string
"""
pass
class RuleBasedAgent(BaseAgent):
"""
A simple rule-based agent that responds based on keyword matching.
This agent does not use LLMs - it returns static strings based on
detected keywords in the input text.
"""
def __init__(self, name: str = "RuleBot") -> None:
"""Initialize the rule-based agent."""
super().__init__(name)
self._rules: dict[str, str] = {
"attack": "Defending! Shield activated!",
"heal": "Healing spell cast! +50 HP",
"run": "Too slow! You can't escape!",
"help": "Allies are on the way!",
"fire": "Water shield deployed!",
"magic": "Counter-spell activated!",
}
self._default_response = "I heard you! Processing command..."
def process_input(self, text: str) -> str:
"""
Process input and return response based on keyword rules.
Args:
text: The input text to analyze
Returns:
Response string based on matched keywords
"""
text_lower = text.lower()
for keyword, response in self._rules.items():
if keyword in text_lower:
logger.info(f"Agent matched keyword: {keyword}")
return f"{self.name}: {response}"
logger.info("Agent using default response")
return f"{self.name}: {self._default_response}"

145
backend/app/engine.py Normal file
View File

@@ -0,0 +1,145 @@
"""
Core Game Engine - The "Heartbeat" of the game.
Runs the main game loop and coordinates between comments and agents.
"""
import asyncio
import logging
import random
import time
from typing import TYPE_CHECKING
from .schemas import GameEvent, EventType
from .agents import BaseAgent, RuleBasedAgent
if TYPE_CHECKING:
from .server import ConnectionManager
logger = logging.getLogger(__name__)
class GameEngine:
"""
The core game engine that runs the main game loop.
Simulates live comments, processes them through agents,
and broadcasts responses to connected clients.
"""
def __init__(self, connection_manager: "ConnectionManager") -> None:
"""
Initialize the game engine.
Args:
connection_manager: The WebSocket connection manager for broadcasting
"""
self._manager = connection_manager
self._agent: BaseAgent = RuleBasedAgent(name="Guardian")
self._running = False
self._tick_count = 0
self._tick_interval = 2.0 # seconds
# Mock comment templates
self._mock_users = ["User123", "GamerPro", "DragonSlayer", "NightOwl", "StarGazer"]
self._mock_actions = ["Attack!", "Heal me!", "Run away!", "Help!", "Fire spell!", "Magic blast!"]
@property
def is_running(self) -> bool:
"""Check if the engine is currently running."""
return self._running
def _generate_mock_comment(self) -> tuple[str, str]:
"""
Generate a mock live comment.
Returns:
Tuple of (username, comment_text)
"""
user = random.choice(self._mock_users)
action = random.choice(self._mock_actions)
return user, action
async def _broadcast_event(self, event_type: str, data: dict) -> None:
"""
Create and broadcast a game event.
Args:
event_type: Type of the event
data: Event payload data
"""
event = GameEvent(
event_type=event_type,
timestamp=time.time(),
data=data
)
await self._manager.broadcast(event)
async def process_comment(self, user: str, message: str) -> None:
"""
Process a comment (real or mock) through the agent pipeline.
Args:
user: Username of the commenter
message: The comment text
"""
# Broadcast the incoming comment
await self._broadcast_event(
EventType.COMMENT,
{"user": user, "message": message}
)
# Process through agent
full_comment = f"{user}: {message}"
response = self._agent.process_input(full_comment)
# Broadcast agent response
await self._broadcast_event(
EventType.AGENT_RESPONSE,
{"agent": self._agent.name, "response": response}
)
async def _game_loop(self) -> None:
"""
The main game loop - runs continuously while engine is active.
Every tick:
1. Simulates a mock live comment
2. Passes it to the agent
3. Broadcasts the response
"""
logger.info("Game loop started")
while self._running:
self._tick_count += 1
# Broadcast tick event
await self._broadcast_event(
EventType.TICK,
{"tick": self._tick_count}
)
# Generate and process mock comment
user, message = self._generate_mock_comment()
logger.info(f"Tick {self._tick_count}: {user} says '{message}'")
await self.process_comment(user, message)
# Wait for next tick
await asyncio.sleep(self._tick_interval)
logger.info("Game loop stopped")
async def start(self) -> None:
"""Start the game engine loop as a background task."""
if self._running:
logger.warning("Engine already running")
return
self._running = True
asyncio.create_task(self._game_loop())
logger.info("Game engine started")
async def stop(self) -> None:
"""Stop the game engine loop."""
self._running = False
logger.info("Game engine stopping...")

102
backend/app/main.py Normal file
View File

@@ -0,0 +1,102 @@
"""
FastAPI entry point for the interactive live-stream game backend.
Configures the application, WebSocket routes, and lifecycle events.
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from .server import ConnectionManager
from .engine import GameEngine
from .schemas import GameEvent, ClientMessage, EventType
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Global instances
manager = ConnectionManager()
engine = GameEngine(manager)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifespan manager.
Starts the game engine on startup and stops it on shutdown.
"""
logger.info("Starting application...")
await engine.start()
yield
logger.info("Shutting down application...")
await engine.stop()
# Create FastAPI application
app = FastAPI(
title="The Island - Live Stream Game Backend",
description="Commercial-grade interactive live-stream game backend",
version="0.1.0",
lifespan=lifespan
)
# Configure CORS for frontend access
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root():
"""Health check endpoint."""
return {
"status": "running",
"service": "The Island Game Backend",
"connections": manager.connection_count,
"engine_running": engine.is_running
}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""
WebSocket endpoint for real-time game communication.
Handles client connections and processes incoming messages.
"""
await manager.connect(websocket)
# Send welcome message
welcome = GameEvent(
event_type=EventType.SYSTEM,
data={"message": "Connected to The Island!"}
)
await manager.send_personal(websocket, welcome)
try:
while True:
# Receive and parse client message
data = await websocket.receive_json()
message = ClientMessage(**data)
# Handle mock comment action
if message.action == "send_comment":
user = message.payload.get("user", "Anonymous")
text = message.payload.get("message", "")
await engine.process_comment(user, text)
except WebSocketDisconnect:
manager.disconnect(websocket)
except Exception as e:
logger.error(f"WebSocket error: {e}")
manager.disconnect(websocket)

53
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,53 @@
"""
Pydantic models for the JSON protocol.
Defines standardized structures for game events and messages.
"""
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
import time
class EventType(str, Enum):
"""Enumeration of all possible game event types."""
COMMENT = "comment"
AGENT_RESPONSE = "agent_response"
TICK = "tick"
SYSTEM = "system"
ERROR = "error"
class GameEvent(BaseModel):
"""
Standardized game event structure for WebSocket communication.
Attributes:
event_type: The type of event (comment, agent_response, tick, etc.)
timestamp: Unix timestamp when the event was created
data: Arbitrary payload data for the event
"""
event_type: str = Field(..., description="Type of the game event")
timestamp: float = Field(default_factory=time.time, description="Unix timestamp")
data: dict[str, Any] = Field(default_factory=dict, description="Event payload")
class Config:
json_schema_extra = {
"example": {
"event_type": "comment",
"timestamp": 1704067200.0,
"data": {"user": "User123", "message": "Attack!"}
}
}
class ClientMessage(BaseModel):
"""
Message structure for client-to-server communication.
Attributes:
action: The action the client wants to perform
payload: Data associated with the action
"""
action: str = Field(..., description="Action to perform")
payload: dict[str, Any] = Field(default_factory=dict, description="Action payload")

90
backend/app/server.py Normal file
View File

@@ -0,0 +1,90 @@
"""
WebSocket connection manager and server utilities.
Handles client connections, disconnections, and message broadcasting.
"""
import logging
from typing import Any
from fastapi import WebSocket
from .schemas import GameEvent
logger = logging.getLogger(__name__)
class ConnectionManager:
"""
Manages WebSocket connections for real-time communication.
Handles connection lifecycle and provides broadcast capabilities
to all connected clients.
"""
def __init__(self) -> None:
"""Initialize the connection manager with an empty connection list."""
self._active_connections: list[WebSocket] = []
@property
def connection_count(self) -> int:
"""Return the number of active connections."""
return len(self._active_connections)
async def connect(self, websocket: WebSocket) -> None:
"""
Accept and register a new WebSocket connection.
Args:
websocket: The WebSocket connection to accept
"""
await websocket.accept()
self._active_connections.append(websocket)
logger.info(f"Client connected. Total connections: {self.connection_count}")
def disconnect(self, websocket: WebSocket) -> None:
"""
Remove a WebSocket connection from the active list.
Args:
websocket: The WebSocket connection to remove
"""
if websocket in self._active_connections:
self._active_connections.remove(websocket)
logger.info(f"Client disconnected. Total connections: {self.connection_count}")
async def broadcast(self, event: GameEvent) -> None:
"""
Send a GameEvent to all connected clients.
Args:
event: The GameEvent to broadcast
"""
if not self._active_connections:
return
message = event.model_dump_json()
disconnected: list[WebSocket] = []
for connection in self._active_connections:
try:
await connection.send_text(message)
except Exception as e:
logger.warning(f"Failed to send to client: {e}")
disconnected.append(connection)
# Clean up failed connections
for conn in disconnected:
self.disconnect(conn)
async def send_personal(self, websocket: WebSocket, event: GameEvent) -> None:
"""
Send a GameEvent to a specific client.
Args:
websocket: The target WebSocket connection
event: The GameEvent to send
"""
try:
await websocket.send_text(event.model_dump_json())
except Exception as e:
logger.warning(f"Failed to send personal message: {e}")
self.disconnect(websocket)

4
backend/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
websockets>=12.0
pydantic>=2.5.0

157
frontend/app.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* The Island - Debug Client JavaScript
* Handles WebSocket connection and UI interactions
*/
let ws = null;
const WS_URL = 'ws://localhost:8000/ws';
// DOM Elements
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const connectBtn = document.getElementById('connectBtn');
const eventLog = document.getElementById('eventLog');
const usernameInput = document.getElementById('username');
const messageInput = document.getElementById('message');
const autoScrollCheckbox = document.getElementById('autoScroll');
/**
* Toggle WebSocket connection
*/
function toggleConnection() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
} else {
connect();
}
}
/**
* Establish WebSocket connection
*/
function connect() {
statusText.textContent = 'Connecting...';
connectBtn.disabled = true;
ws = new WebSocket(WS_URL);
ws.onopen = () => {
statusDot.classList.add('connected');
statusText.textContent = 'Connected';
connectBtn.textContent = 'Disconnect';
connectBtn.disabled = false;
logEvent({ event_type: 'system', data: { message: 'WebSocket connected' } });
};
ws.onclose = () => {
statusDot.classList.remove('connected');
statusText.textContent = 'Disconnected';
connectBtn.textContent = 'Connect';
connectBtn.disabled = false;
logEvent({ event_type: 'system', data: { message: 'WebSocket disconnected' } });
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
logEvent({ event_type: 'error', data: { message: 'Connection error' } });
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
logEvent(data);
} catch (e) {
console.error('Failed to parse message:', e);
}
};
}
/**
* Send a mock comment to the server
*/
function sendComment() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('Not connected to server');
return;
}
const user = usernameInput.value.trim() || 'Anonymous';
const message = messageInput.value.trim();
if (!message) {
alert('Please enter a message');
return;
}
const payload = {
action: 'send_comment',
payload: { user, message }
};
ws.send(JSON.stringify(payload));
messageInput.value = '';
}
/**
* Format timestamp for display
*/
function formatTime(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString();
}
/**
* Format event data for display
*/
function formatEventData(eventType, data) {
switch (eventType) {
case 'comment':
return `${data.user}: ${data.message}`;
case 'agent_response':
return data.response;
case 'tick':
return `Tick #${data.tick}`;
case 'system':
case 'error':
return data.message;
default:
return JSON.stringify(data);
}
}
/**
* Log an event to the display
*/
function logEvent(event) {
const eventType = event.event_type || 'unknown';
const timestamp = event.timestamp || Date.now() / 1000;
const data = event.data || {};
const div = document.createElement('div');
div.className = `event ${eventType}`;
div.innerHTML = `
<span class="event-time">${formatTime(timestamp)}</span>
<span class="event-type">${eventType}</span>
<div class="event-data">${formatEventData(eventType, data)}</div>
`;
eventLog.appendChild(div);
if (autoScrollCheckbox.checked) {
eventLog.scrollTop = eventLog.scrollHeight;
}
}
/**
* Clear the event log
*/
function clearLog() {
eventLog.innerHTML = '';
}
// Allow Enter key to send comment
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendComment();
}
});

179
frontend/debug_client.html Normal file
View File

@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Island - Debug Client</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 20px;
color: #00d4ff;
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(255, 255, 255, 0.1);
padding: 10px 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff4444;
}
.status-dot.connected {
background: #44ff44;
box-shadow: 0 0 10px rgba(68, 255, 68, 0.5);
}
.panels {
display: flex;
flex-direction: column;
gap: 20px;
}
.panel {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 20px;
}
.panel h2 {
color: #00d4ff;
margin-bottom: 15px;
font-size: 1.1rem;
}
.input-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
input[type="text"] {
flex: 1;
min-width: 150px;
padding: 10px 15px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 14px;
}
input[type="text"]:focus {
outline: none;
border-color: #00d4ff;
}
button {
padding: 10px 20px;
border: none;
border-radius: 6px;
background: #00d4ff;
color: #1a1a2e;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background: #00b8e6;
transform: translateY(-1px);
}
button:disabled {
background: #666;
cursor: not-allowed;
}
.controls {
display: flex;
gap: 15px;
align-items: center;
margin-bottom: 10px;
}
.controls label {
display: flex;
align-items: center;
gap: 5px;
font-size: 14px;
}
.event-log {
height: 400px;
overflow-y: auto;
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 15px;
font-family: 'Consolas', monospace;
font-size: 13px;
}
.event {
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
border-left: 3px solid;
}
.event.comment { border-color: #ffaa00; background: rgba(255, 170, 0, 0.1); }
.event.agent_response { border-color: #00ff88; background: rgba(0, 255, 136, 0.1); }
.event.tick { border-color: #888; background: rgba(136, 136, 136, 0.1); }
.event.system { border-color: #00d4ff; background: rgba(0, 212, 255, 0.1); }
.event.error { border-color: #ff4444; background: rgba(255, 68, 68, 0.1); }
.event-time { color: #888; font-size: 11px; }
.event-type { font-weight: bold; text-transform: uppercase; font-size: 11px; }
.event-data { margin-top: 5px; }
</style>
</head>
<body>
<div class="container">
<h1>The Island - Debug Client</h1>
<div class="status-bar">
<div class="status-indicator">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Disconnected</span>
</div>
<button id="connectBtn" onclick="toggleConnection()">Connect</button>
</div>
<div class="panels">
<div class="panel">
<h2>Send Mock Comment</h2>
<div class="input-group">
<input type="text" id="username" placeholder="Username" value="TestUser">
<input type="text" id="message" placeholder="Message (try: Attack!, Heal, Help...)">
<button onclick="sendComment()">Send</button>
</div>
</div>
<div class="panel">
<h2>Event Log</h2>
<div class="controls">
<button onclick="clearLog()">Clear Log</button>
<label><input type="checkbox" id="autoScroll" checked> Auto-scroll</label>
</div>
<div class="event-log" id="eventLog"></div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

16
run.py Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""
Startup script for The Island game backend.
Runs the FastAPI server with uvicorn and hot-reloading enabled.
"""
import uvicorn
if __name__ == "__main__":
uvicorn.run(
"backend.app.main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)