Added better Cleint Interpolation and made synced mouse positions

This commit is contained in:
OusmBlueNinja 2025-03-25 13:08:42 -05:00
parent ca656d1ca8
commit 3aed5da246
2 changed files with 238 additions and 169 deletions

214
client.py
View File

@ -1,6 +1,6 @@
import socket import socket
import threading import threading
import json import pickle
import pygame import pygame
import sys import sys
import time import time
@ -8,64 +8,88 @@ import time
HOST = '127.0.0.1' HOST = '127.0.0.1'
PORT = 65432 PORT = 65432
MOVE_SPEED = 200 # pixels per second MOVE_SPEED = 200 # pixels per second
FAKE_LATENCY = 0.1 # seconds of artificial latency for both sending and receiving
class Event:
def __init__(self, name, payload):
self.name = name
self.payload = payload
def recvall(sock, n):
data = b''
while len(data) < n:
packet = sock.recv(n - len(data))
if not packet:
return None
data += packet
return data
def send_event(sock, event):
data = pickle.dumps(event)
length = len(data)
sock.sendall(length.to_bytes(4, byteorder='big') + data)
def recv_event(sock):
raw_len = recvall(sock, 4)
if not raw_len:
return None
msg_len = int.from_bytes(raw_len, byteorder='big')
data = recvall(sock, msg_len)
return pickle.loads(data)
# Global state # Global state
players = {} # Server-authoritative state: client_id -> {"username", "x", "y"} players = {} # Server-authoritative state: client_id -> {"username", "x", "y", "mouse_x", "mouse_y"}
my_client_id = None # Our unique client id from the server my_client_id = None # Our unique client id from the server
predicted_state = {
# Local predicted state for the local player. "x": 100, "y": 100,
predicted_state = {"x": 100, "y": 100, "keys": set(), "username": "Guest"} "keys": set(),
"username": "Guest",
ping = 0 # measured ping in seconds "mouse_x": 0,
"mouse_y": 0
}
ping = 0 # measured ping in seconds
server_details = {} # Updated from server_info and state_update events
def listen_to_server(sock): def listen_to_server(sock):
""" global my_client_id, players, predicted_state, ping, server_details
Listen for incoming messages from the server and update local state.
Also performs reconciliation by snapping to the server state when no keys are pressed.
Simulates network latency on incoming messages.
"""
global my_client_id, players, predicted_state, ping
file_obj = sock.makefile('r')
while True: while True:
try: try:
line = file_obj.readline() event = recv_event(sock)
if not line: if event is None:
break break
# Simulate fake latency for incoming messages. if event.name == "self_id":
time.sleep(FAKE_LATENCY)
msg = json.loads(line)
event_name = msg.get('event')
payload = msg.get('payload')
if event_name == "self_id":
if my_client_id is None: if my_client_id is None:
my_client_id = payload.get("client_id") my_client_id = event.payload.get("client_id")
print(f"[INFO] My client ID: {my_client_id}") print(f"[INFO] My client ID: {my_client_id}")
elif event_name == "client_connect": elif event.name == "server_info":
print(f"[INFO] Client connected: {payload.get('client_id')}") server_details = event.payload
elif event_name == "client_disconnect": print(f"[INFO] Received server info: {server_details}")
client_id = payload.get('client_id') elif event.name == "client_connect":
print(f"[INFO] Client disconnected: {client_id}") print(f"[INFO] Client connected: {event.payload.get('client_id')}")
if client_id in players: elif event.name == "client_disconnect":
del players[client_id] cid = event.payload.get("client_id")
elif event_name == "state_update": print(f"[INFO] Client disconnected: {cid}")
# Update our players dictionary from the server. if cid in players:
players = payload.get("players", {}) del players[cid]
# If we have an update for our own player, reconcile. elif event.name == "state_update":
players = event.payload.get("players", {})
# Update dynamic server details.
if "total_users" in event.payload:
server_details["total_users"] = event.payload["total_users"]
if "max_users" in event.payload:
server_details["max_users"] = event.payload["max_users"]
if my_client_id and my_client_id in players: if my_client_id and my_client_id in players:
server_x = players[my_client_id].get("x", 100) server_x = players[my_client_id].get("x", 100)
server_y = players[my_client_id].get("y", 100) server_y = players[my_client_id].get("y", 100)
tickrate = server_details.get("tickrate", 64)
correction_factor = 0.1 * (64 / tickrate)
if not predicted_state["keys"]: if not predicted_state["keys"]:
# Snap immediately if no movement is occurring.
predicted_state["x"] = server_x predicted_state["x"] = server_x
predicted_state["y"] = server_y predicted_state["y"] = server_y
else: else:
# If keys are pressed, nudge the predicted state gradually.
correction_factor = 0.1 # 10% correction per update while moving.
predicted_state["x"] += (server_x - predicted_state["x"]) * correction_factor predicted_state["x"] += (server_x - predicted_state["x"]) * correction_factor
predicted_state["y"] += (server_y - predicted_state["y"]) * correction_factor predicted_state["y"] += (server_y - predicted_state["y"]) * correction_factor
elif event_name == "pong": elif event.name == "pong":
sent_time = payload.get('timestamp') sent_time = event.payload.get("timestamp")
if sent_time: if sent_time:
round_trip = time.time() - sent_time round_trip = time.time() - sent_time
ping = round_trip ping = round_trip
@ -74,21 +98,13 @@ def listen_to_server(sock):
print(f"[ERROR] {e}") print(f"[ERROR] {e}")
break break
def send_message(sock, msg_dict): def send_message(sock, event):
""" send_event(sock, event)
Send a JSON message to the server with fake latency.
"""
time.sleep(FAKE_LATENCY) # simulate network latency on outgoing messages
message = json.dumps(msg_dict) + "\n"
sock.sendall(message.encode())
def ping_loop(sock): def ping_loop(sock):
"""
Periodically send ping messages to measure latency.
"""
while True: while True:
ts = time.time() ts = time.time()
send_message(sock, {'type': 'ping', 'timestamp': ts}) send_message(sock, Event("ping", {"timestamp": ts}))
time.sleep(1) time.sleep(1)
def main(): def main():
@ -96,74 +112,72 @@ def main():
username = input("Enter your username: ") username = input("Enter your username: ")
predicted_state["username"] = username predicted_state["username"] = username
# Connect to the server.
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, PORT)) sock.connect((HOST, PORT))
# Start threads to listen to the server and to send pings.
threading.Thread(target=listen_to_server, args=(sock,), daemon=True).start() threading.Thread(target=listen_to_server, args=(sock,), daemon=True).start()
threading.Thread(target=ping_loop, args=(sock,), daemon=True).start() threading.Thread(target=ping_loop, args=(sock,), daemon=True).start()
# Register for needed events. # Register for events.
default_events = ["client_connect", "client_disconnect", "state_update"] for event_name in ["client_connect", "client_disconnect", "state_update"]:
for event in default_events: send_message(sock, Event("register_event", {"event": event_name}))
send_message(sock, {'type': 'register_event', 'event': event}) print(f"[INFO] Registered for event: {event_name}")
print(f"[INFO] Registered for event: {event}")
# Inform the server of our chosen username. send_message(sock, Event("set_username", {"username": username}))
send_message(sock, {'type': 'set_username', 'username': username})
# Initialize Pygame.
pygame.init() pygame.init()
screen = pygame.display.set_mode((800, 600)) screen = pygame.display.set_mode((800, 600))
pygame.display.set_caption("Pygame Client - Multiplayer (Ghost vs Client)") pygame.display.set_caption("Pygame Client - Multiplayer (Real vs Fake)")
clock = pygame.time.Clock() clock = pygame.time.Clock()
# Define colors. ghost_color = (0, 255, 0) # Ghost: server state (outlined)
ghost_color = (0, 255, 0) # Green for the server (ghost) state. client_color = (0, 0, 255) # Client: predicted state (filled)
client_color = (0, 0, 255) # Blue for the client (predicted) state. other_color = (255, 0, 0) # Remote players
other_color = (255, 0, 0) # Red for other players. remote_mouse_color = (255, 255, 0) # Color for remote mouse pointer (yellow)
# Local set to track pressed keys.
local_keys = set() local_keys = set()
while True: while True:
dt = clock.tick(60) / 1000.0 # Frame time (aiming for 60 FPS) dt = clock.tick(60) / 1000.0
for event in pygame.event.get(): for ev in pygame.event.get():
if event.type == pygame.QUIT: if ev.type == pygame.QUIT:
pygame.quit() pygame.quit()
sys.exit() sys.exit()
elif event.type == pygame.KEYDOWN: elif ev.type == pygame.KEYDOWN:
key_name = None key_name = None
if event.key == pygame.K_LEFT: if ev.key == pygame.K_LEFT:
key_name = "left" key_name = "left"
elif event.key == pygame.K_RIGHT: elif ev.key == pygame.K_RIGHT:
key_name = "right" key_name = "right"
elif event.key == pygame.K_UP: elif ev.key == pygame.K_UP:
key_name = "up" key_name = "up"
elif event.key == pygame.K_DOWN: elif ev.key == pygame.K_DOWN:
key_name = "down" key_name = "down"
if key_name and key_name not in local_keys: if key_name and key_name not in local_keys:
local_keys.add(key_name) local_keys.add(key_name)
predicted_state["keys"].add(key_name) predicted_state["keys"].add(key_name)
send_message(sock, {'type': 'keydown', 'key': key_name}) send_message(sock, Event("keydown", {"key": key_name}))
elif event.type == pygame.KEYUP: elif ev.type == pygame.KEYUP:
key_name = None key_name = None
if event.key == pygame.K_LEFT: if ev.key == pygame.K_LEFT:
key_name = "left" key_name = "left"
elif event.key == pygame.K_RIGHT: elif ev.key == pygame.K_RIGHT:
key_name = "right" key_name = "right"
elif event.key == pygame.K_UP: elif ev.key == pygame.K_UP:
key_name = "up" key_name = "up"
elif event.key == pygame.K_DOWN: elif ev.key == pygame.K_DOWN:
key_name = "down" key_name = "down"
if key_name and key_name in local_keys: if key_name and key_name in local_keys:
local_keys.remove(key_name) local_keys.remove(key_name)
if key_name in predicted_state["keys"]: if key_name in predicted_state["keys"]:
predicted_state["keys"].remove(key_name) predicted_state["keys"].remove(key_name)
send_message(sock, {'type': 'keyup', 'key': key_name}) send_message(sock, Event("keyup", {"key": key_name}))
elif ev.type == pygame.MOUSEMOTION:
mouse_x, mouse_y = ev.pos
predicted_state["mouse_x"] = mouse_x
predicted_state["mouse_y"] = mouse_y
send_message(sock, Event("mouse_move", {"x": mouse_x, "y": mouse_y}))
# Update the predicted state based on locally pressed keys. # Update predicted state locally.
dx, dy = 0, 0 dx, dy = 0, 0
if "left" in predicted_state["keys"]: if "left" in predicted_state["keys"]:
dx -= MOVE_SPEED * dt dx -= MOVE_SPEED * dt
@ -176,17 +190,15 @@ def main():
predicted_state["x"] += dx predicted_state["x"] += dx
predicted_state["y"] += dy predicted_state["y"] += dy
# Get ghost (server) position without interpolation. # For our own player, the ghost (server) position is used as the "real" state.
if my_client_id and my_client_id in players: if my_client_id and my_client_id in players:
ghost_x = players[my_client_id].get("x", 100) ghost_x = players[my_client_id].get("x", 100)
ghost_y = players[my_client_id].get("y", 100) ghost_y = players[my_client_id].get("y", 100)
else: else:
ghost_x, ghost_y = predicted_state["x"], predicted_state["y"] ghost_x, ghost_y = predicted_state["x"], predicted_state["y"]
# Render the scene.
screen.fill((0, 0, 0)) screen.fill((0, 0, 0))
# Draw remote players (only real state).
# Draw remote players (using server state).
for cid, data in players.items(): for cid, data in players.items():
if cid == my_client_id: if cid == my_client_id:
continue continue
@ -198,15 +210,18 @@ def main():
text_surface = font.render(uname, True, (255, 255, 255)) text_surface = font.render(uname, True, (255, 255, 255))
text_rect = text_surface.get_rect(center=(x, y - 30)) text_rect = text_surface.get_rect(center=(x, y - 30))
screen.blit(text_surface, text_rect) screen.blit(text_surface, text_rect)
# Draw remote mouse pointer if available.
# Draw the ghost (server-authoritative) position as an outlined circle. if "mouse_x" in data and "mouse_y" in data:
mx = int(data.get("mouse_x", 0))
my = int(data.get("mouse_y", 0))
pygame.draw.circle(screen, remote_mouse_color, (mx, my), 5)
# Draw our ghost (server-authoritative) as an outlined circle.
font = pygame.font.SysFont(None, 24) font = pygame.font.SysFont(None, 24)
ghost_text = font.render("Ghost", True, (255, 255, 255)) ghost_text = font.render("Ghost", True, (255, 255, 255))
ghost_rect = ghost_text.get_rect(center=(int(ghost_x), int(ghost_y) - 30)) ghost_rect = ghost_text.get_rect(center=(int(ghost_x), int(ghost_y) - 30))
pygame.draw.circle(screen, ghost_color, (int(ghost_x), int(ghost_y)), 20, 2) pygame.draw.circle(screen, ghost_color, (int(ghost_x), int(ghost_y)), 20, 2)
screen.blit(ghost_text, ghost_rect) screen.blit(ghost_text, ghost_rect)
# Draw our client-predicted player as a filled circle.
# Draw the client-predicted position as the actual player (filled circle).
pred_x = int(predicted_state.get("x", 100)) pred_x = int(predicted_state.get("x", 100))
pred_y = int(predicted_state.get("y", 100)) pred_y = int(predicted_state.get("y", 100))
font = pygame.font.SysFont(None, 24) font = pygame.font.SysFont(None, 24)
@ -214,12 +229,21 @@ def main():
client_rect = client_text.get_rect(center=(pred_x, pred_y - 30)) client_rect = client_text.get_rect(center=(pred_x, pred_y - 30))
pygame.draw.circle(screen, client_color, (pred_x, pred_y), 20) pygame.draw.circle(screen, client_color, (pred_x, pred_y), 20)
screen.blit(client_text, client_rect) screen.blit(client_text, client_rect)
# Display ping.
# Display ping on the screen.
ping_font = pygame.font.SysFont(None, 24) ping_font = pygame.font.SysFont(None, 24)
ping_surface = ping_font.render(f"Ping: {int(ping*1000)} ms", True, (255, 255, 0)) ping_surface = ping_font.render(f"Ping: {int(ping*1000)} ms", True, (255, 255, 0))
screen.blit(ping_surface, (10, 10)) screen.blit(ping_surface, (10, 10))
# Display HUD info: total users, client ID, and tickrate.
hud_font = pygame.font.SysFont(None, 24)
total_users = server_details.get("total_users", len(players))
max_users = server_details.get("max_users", "?")
tickrate = server_details.get("tickrate", 64)
hud_text = hud_font.render(
f"Users: {total_users}/{max_users} | Client ID: {my_client_id or 'N/A'} | Tickrate: {tickrate} tps",
True,
(255, 255, 255)
)
screen.blit(hud_text, (10, 40))
pygame.display.flip() pygame.display.flip()
if __name__ == "__main__": if __name__ == "__main__":

191
server.py
View File

@ -1,78 +1,124 @@
import socket import socket
import threading import threading
import json import pickle
import time import time
import uuid import uuid
HOST = '127.0.0.1' HOST = '127.0.0.1'
PORT = 65432 PORT = 65432
# Global dictionaries for event subscriptions, client IDs, and game state.
event_subscriptions = {} # event_name -> list of client sockets
client_ids = {} # conn -> client_id
client_states = {} # conn -> {"client_id", "username", "x", "y", "keys"}
lock = threading.Lock()
MOVE_SPEED = 200 # pixels per second MOVE_SPEED = 200 # pixels per second
TICKRATE = 64 # ticks per second
MAX_USERS = 100
class Event:
def __init__(self, name, payload):
self.name = name
self.payload = payload
def recvall(sock, n):
data = b''
while len(data) < n:
packet = sock.recv(n - len(data))
if not packet:
return None
data += packet
return data
def send_event(sock, event):
data = pickle.dumps(event)
length = len(data)
sock.sendall(length.to_bytes(4, byteorder='big') + data)
def recv_event(sock):
raw_len = recvall(sock, 4)
if not raw_len:
return None
msg_len = int.from_bytes(raw_len, byteorder='big')
data = recvall(sock, msg_len)
return pickle.loads(data)
# Global dictionaries
event_subscriptions = {} # event name -> list of client sockets
client_ids = {} # conn -> client_id
client_states = {} # conn -> {"client_id", "username", "x", "y", "keys", "mouse_x", "mouse_y"}
lock = threading.Lock()
def handle_client(conn, addr): def handle_client(conn, addr):
client_id = str(uuid.uuid4()) client_id = str(uuid.uuid4())
with lock: with lock:
client_ids[conn] = client_id client_ids[conn] = client_id
# Initialize state: default username "Guest", starting position, and an empty set of pressed keys. # Initialize state for new client.
client_states[conn] = {"client_id": client_id, "username": "Guest", "x": 100, "y": 100, "keys": set()} client_states[conn] = {
"client_id": client_id,
"username": "Guest",
"x": 100,
"y": 100,
"keys": set(),
"mouse_x": 0,
"mouse_y": 0
}
print(f"[NEW CONNECTION] {addr} connected as {client_id}") print(f"[NEW CONNECTION] {addr} connected as {client_id}")
# Send a dedicated "self_id" event so the client knows its unique id.
try: try:
msg = json.dumps({'event': 'self_id', 'payload': {'client_id': client_id}}) + "\n" # Send self_id so the client knows its unique id.
conn.sendall(msg.encode()) send_event(conn, Event("self_id", {"client_id": client_id}))
# Send server info.
server_info = {
"tickrate": TICKRATE,
"total_users": len(client_states),
"max_users": MAX_USERS,
"server_ip": HOST,
"server_port": PORT,
"server_name": "My Multiplayer Server"
}
send_event(conn, Event("server_info", server_info))
except Exception as e: except Exception as e:
print(f"[ERROR] sending self_id: {e}") print(f"[ERROR] sending self_id/server_info: {e}")
# Broadcast that a new client connected. # Broadcast that a new client connected.
send_event("client_connect", {"client_id": client_id}) broadcast_event("client_connect", {"client_id": client_id})
try: try:
file_obj = conn.makefile('r') while True:
for line in file_obj: event = recv_event(conn)
line = line.strip() if event is None:
if not line: break
continue if event.name == "register_event":
try: event_name = event.payload.get('event')
msg = json.loads(line) with lock:
msg_type = msg.get('type') event_subscriptions.setdefault(event_name, []).append(conn)
if msg_type == 'register_event': print(f"[REGISTER] {client_id} subscribed to '{event_name}'")
event_name = msg.get('event') elif event.name == "set_username":
with lock: username = event.payload.get('username', 'Guest')
event_subscriptions.setdefault(event_name, []).append(conn) with lock:
print(f"[REGISTER] {client_id} subscribed to '{event_name}'") if conn in client_states:
elif msg_type == 'set_username': client_states[conn]['username'] = username
username = msg.get('username', 'Guest') print(f"[USERNAME] {client_id} set username to '{username}'")
with lock: elif event.name == "keydown":
if conn in client_states: key = event.payload.get('key')
client_states[conn]['username'] = username with lock:
print(f"[USERNAME] {client_id} set username to '{username}'") if conn in client_states:
elif msg_type == 'keydown': client_states[conn]['keys'].add(key)
key = msg.get('key') print(f"[KEYDOWN] {client_id} key '{key}' pressed")
with lock: elif event.name == "keyup":
if conn in client_states: key = event.payload.get('key')
client_states[conn]['keys'].add(key) with lock:
print(f"[KEYDOWN] {client_id} key '{key}' pressed") if conn in client_states and key in client_states[conn]['keys']:
elif msg_type == 'keyup': client_states[conn]['keys'].remove(key)
key = msg.get('key') print(f"[KEYUP] {client_id} key '{key}' released")
with lock: elif event.name == "mouse_move":
if conn in client_states and key in client_states[conn]['keys']: x = event.payload.get('x', 0)
client_states[conn]['keys'].remove(key) y = event.payload.get('y', 0)
print(f"[KEYUP] {client_id} key '{key}' released") with lock:
elif msg_type == 'ping': if conn in client_states:
# Respond immediately with a pong, echoing the timestamp. client_states[conn]['mouse_x'] = x
timestamp = msg.get('timestamp') client_states[conn]['mouse_y'] = y
response = json.dumps({'event': 'pong', 'payload': {'timestamp': timestamp}}) + "\n" # Optionally log mouse moves.
conn.sendall(response.encode()) # print(f"[MOUSE_MOVE] {client_id} moved mouse to ({x}, {y})")
# Additional message types can be handled here. elif event.name == "ping":
except json.JSONDecodeError: timestamp = event.payload.get('timestamp')
print(f"[ERROR] Invalid JSON from {client_id}: {line}") send_event(conn, Event("pong", {"timestamp": timestamp}))
# You can handle additional events here.
except Exception as e: except Exception as e:
print(f"[ERROR] Exception with {client_id}: {e}") print(f"[ERROR] Exception with {client_id}: {e}")
finally: finally:
@ -83,34 +129,26 @@ def handle_client(conn, addr):
client_ids.pop(conn, None) client_ids.pop(conn, None)
client_states.pop(conn, None) client_states.pop(conn, None)
print(f"[DISCONNECT] {client_id} disconnected.") print(f"[DISCONNECT] {client_id} disconnected.")
send_event("client_disconnect", {"client_id": client_id}) broadcast_event("client_disconnect", {"client_id": client_id})
conn.close() conn.close()
def send_event(event_name, payload): def broadcast_event(event_name, payload):
""" event = Event(event_name, payload)
Broadcast an event to all clients subscribed to the given event.
"""
message = json.dumps({'event': event_name, 'payload': payload}) + "\n"
with lock: with lock:
receivers = event_subscriptions.get(event_name, []) receivers = event_subscriptions.get(event_name, [])
for client in receivers[:]: # iterate over a copy to allow removals for client in receivers[:]:
try: try:
client.sendall(message.encode()) send_event(client, event)
except Exception as e: except Exception as e:
print(f"[ERROR] Failed to send to client, removing: {e}") print(f"[ERROR] Failed to send to client, removing: {e}")
receivers.remove(client) receivers.remove(client)
def game_loop(): def game_loop():
""" dt = 1 / TICKRATE # Tick interval
Main game loop: update each clients position based on currently pressed keys,
and broadcast the overall game state (including server time) to all clients.
"""
dt = 0.1 #100 ms;
while True: while True:
time.sleep(dt) time.sleep(dt)
with lock: with lock:
# Update each client's position. # Update each client's position based on pressed keys.
for state in client_states.values(): for state in client_states.values():
dx, dy = 0, 0 dx, dy = 0, 0
if 'left' in state['keys']: if 'left' in state['keys']:
@ -123,16 +161,23 @@ def game_loop():
dy += MOVE_SPEED * dt dy += MOVE_SPEED * dt
state['x'] += dx state['x'] += dx
state['y'] += dy state['y'] += dy
# Build payload: include current server time and each clients state. # Build payload for state_update.
players_payload = {} players_payload = {}
for state in client_states.values(): for state in client_states.values():
players_payload[state['client_id']] = { players_payload[state['client_id']] = {
"username": state['username'], "username": state['username'],
"x": state['x'], "x": state['x'],
"y": state['y'] "y": state['y'],
"mouse_x": state.get('mouse_x', 0),
"mouse_y": state.get('mouse_y', 0)
} }
payload = {"server_time": time.time(), "players": players_payload} total_users = len(client_states)
send_event("state_update", payload) payload = {
"players": players_payload,
"total_users": total_users,
"max_users": MAX_USERS
}
broadcast_event("state_update", payload)
def start_server(): def start_server():
print("[STARTING] Server is starting...") print("[STARTING] Server is starting...")