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

216
client.py
View File

@ -1,6 +1,6 @@
import socket
import threading
import json
import pickle
import pygame
import sys
import time
@ -8,64 +8,88 @@ import time
HOST = '127.0.0.1'
PORT = 65432
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
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
# Local predicted state for the local player.
predicted_state = {"x": 100, "y": 100, "keys": set(), "username": "Guest"}
ping = 0 # measured ping in seconds
predicted_state = {
"x": 100, "y": 100,
"keys": set(),
"username": "Guest",
"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):
"""
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')
global my_client_id, players, predicted_state, ping, server_details
while True:
try:
line = file_obj.readline()
if not line:
event = recv_event(sock)
if event is None:
break
# Simulate fake latency for incoming messages.
time.sleep(FAKE_LATENCY)
msg = json.loads(line)
event_name = msg.get('event')
payload = msg.get('payload')
if event_name == "self_id":
if event.name == "self_id":
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}")
elif event_name == "client_connect":
print(f"[INFO] Client connected: {payload.get('client_id')}")
elif event_name == "client_disconnect":
client_id = payload.get('client_id')
print(f"[INFO] Client disconnected: {client_id}")
if client_id in players:
del players[client_id]
elif event_name == "state_update":
# Update our players dictionary from the server.
players = payload.get("players", {})
# If we have an update for our own player, reconcile.
elif event.name == "server_info":
server_details = event.payload
print(f"[INFO] Received server info: {server_details}")
elif event.name == "client_connect":
print(f"[INFO] Client connected: {event.payload.get('client_id')}")
elif event.name == "client_disconnect":
cid = event.payload.get("client_id")
print(f"[INFO] Client disconnected: {cid}")
if cid in players:
del players[cid]
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:
server_x = players[my_client_id].get("x", 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"]:
# Snap immediately if no movement is occurring.
predicted_state["x"] = server_x
predicted_state["y"] = server_y
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["y"] += (server_y - predicted_state["y"]) * correction_factor
elif event_name == "pong":
sent_time = payload.get('timestamp')
elif event.name == "pong":
sent_time = event.payload.get("timestamp")
if sent_time:
round_trip = time.time() - sent_time
ping = round_trip
@ -74,21 +98,13 @@ def listen_to_server(sock):
print(f"[ERROR] {e}")
break
def send_message(sock, msg_dict):
"""
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 send_message(sock, event):
send_event(sock, event)
def ping_loop(sock):
"""
Periodically send ping messages to measure latency.
"""
while True:
ts = time.time()
send_message(sock, {'type': 'ping', 'timestamp': ts})
send_message(sock, Event("ping", {"timestamp": ts}))
time.sleep(1)
def main():
@ -96,74 +112,72 @@ def main():
username = input("Enter your username: ")
predicted_state["username"] = username
# Connect to the server.
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
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=ping_loop, args=(sock,), daemon=True).start()
# Register for needed events.
default_events = ["client_connect", "client_disconnect", "state_update"]
for event in default_events:
send_message(sock, {'type': 'register_event', 'event': event})
print(f"[INFO] Registered for event: {event}")
# Register for events.
for event_name in ["client_connect", "client_disconnect", "state_update"]:
send_message(sock, Event("register_event", {"event": event_name}))
print(f"[INFO] Registered for event: {event_name}")
# Inform the server of our chosen username.
send_message(sock, {'type': 'set_username', 'username': username})
send_message(sock, Event("set_username", {"username": username}))
# Initialize Pygame.
pygame.init()
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()
# Define colors.
ghost_color = (0, 255, 0) # Green for the server (ghost) state.
client_color = (0, 0, 255) # Blue for the client (predicted) state.
other_color = (255, 0, 0) # Red for other players.
# Local set to track pressed keys.
ghost_color = (0, 255, 0) # Ghost: server state (outlined)
client_color = (0, 0, 255) # Client: predicted state (filled)
other_color = (255, 0, 0) # Remote players
remote_mouse_color = (255, 255, 0) # Color for remote mouse pointer (yellow)
local_keys = set()
while True:
dt = clock.tick(60) / 1000.0 # Frame time (aiming for 60 FPS)
for event in pygame.event.get():
if event.type == pygame.QUIT:
dt = clock.tick(60) / 1000.0
for ev in pygame.event.get():
if ev.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.KEYDOWN:
elif ev.type == pygame.KEYDOWN:
key_name = None
if event.key == pygame.K_LEFT:
if ev.key == pygame.K_LEFT:
key_name = "left"
elif event.key == pygame.K_RIGHT:
elif ev.key == pygame.K_RIGHT:
key_name = "right"
elif event.key == pygame.K_UP:
elif ev.key == pygame.K_UP:
key_name = "up"
elif event.key == pygame.K_DOWN:
elif ev.key == pygame.K_DOWN:
key_name = "down"
if key_name and key_name not in local_keys:
local_keys.add(key_name)
predicted_state["keys"].add(key_name)
send_message(sock, {'type': 'keydown', 'key': key_name})
elif event.type == pygame.KEYUP:
send_message(sock, Event("keydown", {"key": key_name}))
elif ev.type == pygame.KEYUP:
key_name = None
if event.key == pygame.K_LEFT:
if ev.key == pygame.K_LEFT:
key_name = "left"
elif event.key == pygame.K_RIGHT:
elif ev.key == pygame.K_RIGHT:
key_name = "right"
elif event.key == pygame.K_UP:
elif ev.key == pygame.K_UP:
key_name = "up"
elif event.key == pygame.K_DOWN:
elif ev.key == pygame.K_DOWN:
key_name = "down"
if key_name and key_name in local_keys:
local_keys.remove(key_name)
if key_name in predicted_state["keys"]:
predicted_state["keys"].remove(key_name)
send_message(sock, {'type': 'keyup', 'key': key_name})
# Update the predicted state based on locally pressed keys.
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 predicted state locally.
dx, dy = 0, 0
if "left" in predicted_state["keys"]:
dx -= MOVE_SPEED * dt
@ -176,17 +190,15 @@ def main():
predicted_state["x"] += dx
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:
ghost_x = players[my_client_id].get("x", 100)
ghost_y = players[my_client_id].get("y", 100)
else:
ghost_x, ghost_y = predicted_state["x"], predicted_state["y"]
# Render the scene.
screen.fill((0, 0, 0))
# Draw remote players (using server state).
# Draw remote players (only real state).
for cid, data in players.items():
if cid == my_client_id:
continue
@ -198,15 +210,18 @@ def main():
text_surface = font.render(uname, True, (255, 255, 255))
text_rect = text_surface.get_rect(center=(x, y - 30))
screen.blit(text_surface, text_rect)
# Draw the ghost (server-authoritative) position as an outlined circle.
# Draw remote mouse pointer if available.
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)
ghost_text = font.render("Ghost", True, (255, 255, 255))
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)
screen.blit(ghost_text, ghost_rect)
# Draw the client-predicted position as the actual player (filled circle).
# Draw our client-predicted player as a filled circle.
pred_x = int(predicted_state.get("x", 100))
pred_y = int(predicted_state.get("y", 100))
font = pygame.font.SysFont(None, 24)
@ -214,12 +229,21 @@ def main():
client_rect = client_text.get_rect(center=(pred_x, pred_y - 30))
pygame.draw.circle(screen, client_color, (pred_x, pred_y), 20)
screen.blit(client_text, client_rect)
# Display ping on the screen.
# Display ping.
ping_font = pygame.font.SysFont(None, 24)
ping_surface = ping_font.render(f"Ping: {int(ping*1000)} ms", True, (255, 255, 0))
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()
if __name__ == "__main__":

191
server.py
View File

@ -1,78 +1,124 @@
import socket
import threading
import json
import pickle
import time
import uuid
HOST = '127.0.0.1'
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
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):
client_id = str(uuid.uuid4())
with lock:
client_ids[conn] = client_id
# Initialize state: default username "Guest", starting position, and an empty set of pressed keys.
client_states[conn] = {"client_id": client_id, "username": "Guest", "x": 100, "y": 100, "keys": set()}
# Initialize state for new client.
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}")
# Send a dedicated "self_id" event so the client knows its unique id.
try:
msg = json.dumps({'event': 'self_id', 'payload': {'client_id': client_id}}) + "\n"
conn.sendall(msg.encode())
# Send self_id so the client knows its unique id.
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:
print(f"[ERROR] sending self_id: {e}")
print(f"[ERROR] sending self_id/server_info: {e}")
# Broadcast that a new client connected.
send_event("client_connect", {"client_id": client_id})
broadcast_event("client_connect", {"client_id": client_id})
try:
file_obj = conn.makefile('r')
for line in file_obj:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
msg_type = msg.get('type')
if msg_type == 'register_event':
event_name = msg.get('event')
with lock:
event_subscriptions.setdefault(event_name, []).append(conn)
print(f"[REGISTER] {client_id} subscribed to '{event_name}'")
elif msg_type == 'set_username':
username = msg.get('username', 'Guest')
with lock:
if conn in client_states:
client_states[conn]['username'] = username
print(f"[USERNAME] {client_id} set username to '{username}'")
elif msg_type == 'keydown':
key = msg.get('key')
with lock:
if conn in client_states:
client_states[conn]['keys'].add(key)
print(f"[KEYDOWN] {client_id} key '{key}' pressed")
elif msg_type == 'keyup':
key = msg.get('key')
with lock:
if conn in client_states and key in client_states[conn]['keys']:
client_states[conn]['keys'].remove(key)
print(f"[KEYUP] {client_id} key '{key}' released")
elif msg_type == 'ping':
# Respond immediately with a pong, echoing the timestamp.
timestamp = msg.get('timestamp')
response = json.dumps({'event': 'pong', 'payload': {'timestamp': timestamp}}) + "\n"
conn.sendall(response.encode())
# Additional message types can be handled here.
except json.JSONDecodeError:
print(f"[ERROR] Invalid JSON from {client_id}: {line}")
while True:
event = recv_event(conn)
if event is None:
break
if event.name == "register_event":
event_name = event.payload.get('event')
with lock:
event_subscriptions.setdefault(event_name, []).append(conn)
print(f"[REGISTER] {client_id} subscribed to '{event_name}'")
elif event.name == "set_username":
username = event.payload.get('username', 'Guest')
with lock:
if conn in client_states:
client_states[conn]['username'] = username
print(f"[USERNAME] {client_id} set username to '{username}'")
elif event.name == "keydown":
key = event.payload.get('key')
with lock:
if conn in client_states:
client_states[conn]['keys'].add(key)
print(f"[KEYDOWN] {client_id} key '{key}' pressed")
elif event.name == "keyup":
key = event.payload.get('key')
with lock:
if conn in client_states and key in client_states[conn]['keys']:
client_states[conn]['keys'].remove(key)
print(f"[KEYUP] {client_id} key '{key}' released")
elif event.name == "mouse_move":
x = event.payload.get('x', 0)
y = event.payload.get('y', 0)
with lock:
if conn in client_states:
client_states[conn]['mouse_x'] = x
client_states[conn]['mouse_y'] = y
# Optionally log mouse moves.
# print(f"[MOUSE_MOVE] {client_id} moved mouse to ({x}, {y})")
elif event.name == "ping":
timestamp = event.payload.get('timestamp')
send_event(conn, Event("pong", {"timestamp": timestamp}))
# You can handle additional events here.
except Exception as e:
print(f"[ERROR] Exception with {client_id}: {e}")
finally:
@ -83,34 +129,26 @@ def handle_client(conn, addr):
client_ids.pop(conn, None)
client_states.pop(conn, None)
print(f"[DISCONNECT] {client_id} disconnected.")
send_event("client_disconnect", {"client_id": client_id})
broadcast_event("client_disconnect", {"client_id": client_id})
conn.close()
def send_event(event_name, payload):
"""
Broadcast an event to all clients subscribed to the given event.
"""
message = json.dumps({'event': event_name, 'payload': payload}) + "\n"
def broadcast_event(event_name, payload):
event = Event(event_name, payload)
with lock:
receivers = event_subscriptions.get(event_name, [])
for client in receivers[:]: # iterate over a copy to allow removals
for client in receivers[:]:
try:
client.sendall(message.encode())
send_event(client, event)
except Exception as e:
print(f"[ERROR] Failed to send to client, removing: {e}")
receivers.remove(client)
def game_loop():
"""
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;
dt = 1 / TICKRATE # Tick interval
while True:
time.sleep(dt)
with lock:
# Update each client's position.
# Update each client's position based on pressed keys.
for state in client_states.values():
dx, dy = 0, 0
if 'left' in state['keys']:
@ -123,16 +161,23 @@ def game_loop():
dy += MOVE_SPEED * dt
state['x'] += dx
state['y'] += dy
# Build payload: include current server time and each clients state.
# Build payload for state_update.
players_payload = {}
for state in client_states.values():
players_payload[state['client_id']] = {
"username": state['username'],
"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}
send_event("state_update", payload)
total_users = len(client_states)
payload = {
"players": players_payload,
"total_users": total_users,
"max_users": MAX_USERS
}
broadcast_event("state_update", payload)
def start_server():
print("[STARTING] Server is starting...")