Games!

import json import perlin_noise import math import simple_socket import socket import time import threading import random from constants import * def dir_dis_to_xy(direction, distance): return ( (distance * math.cos(math.radians(direction))), (distance * math.sin(math.radians(direction))), ) def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? return math.degrees(math.atan2(xy[1], xy[0])), math.sqrt( (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 ) class MarchingSquares: def __init__(self): self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] def set_grid(self, new_grid): self.grid = new_grid def get_grid_value(self, x, y): x1, y1 = int(x), int(y) x2, y2 = min(x1 + 1, ROWS), min(y1 + 1, COLS) dx, dy = x - x1, y - y1 p11 = self.grid[x1][y1] p21 = self.grid[x2][y1] p12 = self.grid[x1][y2] p22 = self.grid[x2][y2] val = ( p11 * (1 - dx) * (1 - dy) + p21 * dx * (1 - dy) + p12 * (1 - dx) * dy + p22 * dx * dy ) return val class Brush: def __init__(self, radius=40, strength=1.0, falloff=1.0): self.radius = radius self.strength = strength self.falloff = falloff def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) inv_r = 1.0 / r grid = marching_squares.grid strength = self.strength falloff = self.falloff for j in range(row_start, row_end): px = j * cs dx_sq = (px - mx) ** 2 row = grid[j] for i in range(col_start, col_end): py = i * cs dy = py - my dist_sq = dy * dy + dx_sq if dist_sq <= r * r: dist = math.sqrt(dist_sq) t = dist * inv_r weight = strength + t * (falloff - strength) old = row[i] row[i] = max(0.0, min(1.0, old + (target_value - old) * weight)) class Environment: def __init__(self): self.terrain_speeds = { "water": 0.6, "forest": 0.8, "plains": 1, "hill": 0.7, "mountain": 3, } self.terrain_attacks = { "water": 0.5, "forest": 0.75, "plains": 1, "hill": 1.5, "mountain": 0, } self.terrain_marching = MarchingSquares() self.forest_marching = MarchingSquares() self.cities = [] self.default_vision = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] for y in range(COLS + 1): for x in range(ROWS + 1): self.default_vision[x][y] = 0.0 self.generate_terrain() self.generate_default_vision() # 2 players left and right most cities # 3 players left-bottom, top, right-bottom # 4 players left-bottom, top-left, top-right, right-bottom # 5 players left-bottom, top-left, middle, top-right, right-bottom # 6 players left-bottom, top-left, middle-left, middle-right, top-right, right-bottom left_bottom_city = min(self.cities, key=lambda c: c.position[0] + c.position[1]) top_left_city = min(self.cities, key=lambda c: c.position[0] - c.position[1]) middle_top_city = min( self.cities, key=lambda c: (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5) + c.position[1], ) middle_bottom_city = max( self.cities, key=lambda c: c.position[1] - (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5), ) top_right_city = max(self.cities, key=lambda c: c.position[0] - c.position[1]) right_bottom_city = max( self.cities, key=lambda c: c.position[0] + c.position[1] ) left_city = min(self.cities, key=lambda c: c.position[0]) right_city = max(self.cities, key=lambda c: c.position[0]) top_city = max(self.cities, key=lambda c: c.position[1]) middle_city = min( self.cities, key=lambda c: abs(c.position[0] - (ROWS * CELL_SIZE) / 2) + abs(c.position[1] - (COLS * CELL_SIZE) / 2), ) if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] self.vision_brush = Brush(75, 1, 0) self.city_vision_brush = Brush(175, 1, 0) self.border_brush = Brush(40, 0.05, 0) self.city_border_brush = Brush(80, 0.05, 0) self.players_in_cities = [[] for _ in self.cities] def generate_terrain(self): def elevation_bias(x, y): cx = ROWS / 2 cy = COLS / 2 dx = abs(x - cx) dy = abs(y - cy) dist = math.sqrt((dx) ** 2 + (dy) ** 2) max_dist = math.sqrt((cx) ** 2 + (cy) ** 2) return 1.0 - (dist / max_dist) noise = perlin_noise.PerlinNoise(octaves=3) for y in range(COLS + 1): for x in range(ROWS + 1): value = max( 0, min(1, ((noise([x / 25, y / 25])) - 0.2) + (elevation_bias(x, y))), ) self.terrain_marching.grid[x][y] = value forest_noise = perlin_noise.PerlinNoise(octaves=1.1) for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] value = (min(0.6, forest_noise([x / 30, y / 30])) * 2.0) + 0.3 plains_diff = max(0, (TERRAIN_VALUES["plains"] + 0.1) - terrain_value) hill_diff = max(0, terrain_value - (TERRAIN_VALUES["hill"] - 0.1)) self.forest_marching.grid[x][y] = ( value - (plains_diff * 10) ) - hill_diff * 10 def within_edges(cx, cy): edge_margin = int(1) return ( cx >= edge_margin and cx <= ROWS - edge_margin and cy >= edge_margin and cy <= COLS - edge_margin ) tries = 0 distance = 15 while True: cx = random.randint(0, ROWS) cy = random.randint(0, COLS) terrain_value = self.terrain_marching.grid[cx][cy] if ( ( terrain_value > TERRAIN_VALUES["plains"] and terrain_value < TERRAIN_VALUES["hill"] ) and all( abs(cx * CELL_SIZE - city.position[0]) + abs(cy * CELL_SIZE - city.position[1]) >= CELL_SIZE * distance for city in self.cities ) and within_edges(cx, cy) and self.forest_marching.grid[cx][cy] < THRESHOLD ): px = cx * CELL_SIZE py = cy * CELL_SIZE self.cities.append(City((px, py))) distance = 15 if len(self.cities) >= 10: break tries += 1 if tries >= 100: distance = max(2, distance - 2) tries = 0 def generate_default_vision(self): for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] forest_value = self.forest_marching.grid[x][y] self.default_vision[x][y] = 0.35 + ( max(min((((terrain_value + 0.1) / 1) + 0.2), 1), 0.2) + (0.8 if forest_value > 0.6 else 0.0) ) def draw_info(self, player): ply = self.players[player] vision_grid = ply.vision.grid border_grid = ply.border.grid troops = [] cities = [ ( c.owner.color if c.owner is not None else None, c.position, c.id, c.path, self.players.index(c.owner) if c.owner is not None else -1, ) for c in self.cities ] for troop in [t for p in self.players for t in p.troops]: ply = self.players[player] vision = ply.vision px, py = troop.position gx = px / CELL_SIZE gy = py / CELL_SIZE gx = max(0, min(ROWS, gx)) gy = max(0, min(COLS, gy)) if vision.get_grid_value(gx, gy) < THRESHOLD: troops.append( ( troop.position, troop.owner.color, troop.id, self.players.index(troop.owner), troop.path, troop.health, ) ) return vision_grid, border_grid, troops, cities def get_terrain_info(self): return ( self.terrain_marching.grid, self.forest_marching.grid, [c.position for c in self.cities], ) def get_terrain_name(self, value, fvalue): if fvalue > THRESHOLD: return "forest" for name, v in reversed(TERRAIN_VALUES.items()): if value > v: return name def update_troops(self, paths_to_apply): # split into more functions ? self.players_in_cities = [[] for _ in self.cities] troop_ids = [info[0] for info in paths_to_apply] troop_paths = [info[1] for info in paths_to_apply] for player in self.players: player.vision.grid = [row[:] for row in self.default_vision] for city in self.cities: if city.owner is player: self.city_vision_brush.apply(player.vision, city.position, 0) self.city_border_brush.apply(player.border, city.position, 1.0) for other_player in self.players: if not player is other_player: for city in self.cities: if city.owner is other_player: self.city_border_brush.apply( player.border, city.position, 0.0 ) to_remove = [] for troop in player.troops: if troop.health <= 0: to_remove.append(troop) continue try: tidx = troop_ids.index(id(troop)) troop.path = troop_paths[tidx] except ValueError: pass old_pos = troop.position owned = [city.position for city in self.cities if city.owner is player] if owned: closest_city = min( owned, key=lambda x: xy_to_dir_dis( ((old_pos[0] - x[0]), (old_pos[1] - x[1])) ), ) city_dir, city_dist = xy_to_dir_dis( ((old_pos[0] - closest_city[0]), (old_pos[1] - closest_city[1])) ) sample_points = [ dir_dis_to_xy(city_dir, dist * 20) for dist in range(int(city_dist // 20)) ] border_avg = 0 if sample_points: border_avgs = [] for other_player in self.players: if other_player is not player: border_avgs.append( sum( [ other_player.border.get_grid_value( (closest_city[0] + s_p[0]) / CELL_SIZE, (closest_city[1] + s_p[1]) / CELL_SIZE, ) for s_p in sample_points ] ) / len(sample_points) ) border_avg = sum(border_avgs) / len(border_avgs) dist_penal = max(((city_dist + 250) / 1000), 0.5) healing_power = (1 - (border_avg / 2)) - dist_penal else: healing_power = -0.5 troop.health += healing_power / 25 if troop.health > 100: troop.health = 100 enemies_in_range = [] gx = old_pos[0] / CELL_SIZE gy = old_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) on_terrain = self.get_terrain_name(terrain, forest) if troop.path: target = troop.path[0] terrain_speed = self.terrain_speeds[on_terrain] dir, distance = xy_to_dir_dis( (target[0] - old_pos[0], target[1] - old_pos[1]) ) distance = terrain_speed * 0.1 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) new_pos = (old_pos[0] + new_off_x, old_pos[1] + new_off_y) for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 14: distance = 14 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain dir, distance = xy_to_dir_dis( (target[0] - troop.position[0], target[1] - troop.position[1]) ) if distance < (terrain_speed * 2): troop.path.pop(0) else: new_pos = old_pos for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 15: distance += 0.025 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain if enemies_in_range: attack_power = self.terrain_attacks[on_terrain] / 25 closest = min(enemies_in_range, key=lambda x: x[1]) closest[0].health -= attack_power if on_terrain == "hill": self.city_vision_brush.apply(player.vision, troop.position, 0) else: self.vision_brush.apply(player.vision, troop.position, 0) self.border_brush.apply(player.border, troop.position, 1.0) for i, city in enumerate(self.cities): cx, cy = city.position tx, ty = troop.position dir, dist = xy_to_dir_dis((tx - cx, ty - cy)) if dist < 15: self.players_in_cities[i].append(player) break to_remove.reverse() for t in to_remove: player.troops.remove(t) def update_cities(self, paths_to_apply): city_ids = [info[0] for info in paths_to_apply] city_paths = [info[1] for info in paths_to_apply] for i, city in enumerate(self.cities): try: cidx = city_ids.index(id(city)) city.path = city_paths[cidx] except ValueError: pass cx, cy = city.position last_owner = city.owner if len(self.players_in_cities[i]) == 1: city.owner = self.players_in_cities[i][0] if last_owner is not city.owner: city.timer = 0 city.path = [] if city.owner is not None: city.timer += 1 t_per_c = len(city.owner.troops) / len( [c for c in self.cities if c.owner == city.owner] ) if city.timer >= 45 * (30 * t_per_c) and t_per_c < 10: city.owner.troops.append( Troop( ( cx + random.randrange(-6, 6), cy + random.randrange(-6, 6), ), city.owner, city.path.copy(), ) ) city.timer = 0 class Troop: def __init__(self, position, owner, path=None): self.position = position self.health = 100 self.path = path if path is not None else [] self.owner = owner self.id = id(self) class City: def __init__(self, position): self.position = position self.timer = 0 self.owner = None self.id = id(self) self.path = [] class Player: def __init__(self, start_pos, color, environment): self.start_pos = start_pos self.color = color self.troops = [Troop(self.start_pos, self)] self.border = MarchingSquares() self.vision = MarchingSquares() self.vision.grid = [row[:] for row in environment.default_vision] class Game: def __init__(self): self.FPS = 45 self.last_time = time.perf_counter() self.frame_time = 1 / self.FPS self.done = False self.server = simple_socket.Server( socket.gethostbyname(str(socket.gethostname())), 1200 ) self.environment = Environment() self.player_inputs = [[] for i in range(PLAYERS)] self.player_city_inputs = [[] for i in range(PLAYERS)] self.player_pause_requests = [False for i in range(PLAYERS)] self.started = False def run_game(self): self.ready = True try: port = int(input("Enter port to use (0 - 99): ")) self.server.port = PORTS[max(0, min(99, port))] except ValueError: pass print("ip: ", self.server.ip, ", port: ", self.server.port) print("starting server...") self.server.start() print("waiting for players...") self.server.lsn(conns=PLAYERS) for player_num in range(PLAYERS): conn, addr = self.server.accept() player_thread = threading.Thread( target=self.handle_player, args=(player_num, conn, addr) ) player_thread.start() print("player: ", player_num, " connected") print("All players connected, starting game!") self.started = True while not self.done: if not all(self.player_pause_requests): self.game_logic() current_time = time.perf_counter() delta_time = current_time - self.last_time self.last_time = current_time if delta_time < self.frame_time: time.sleep(self.frame_time - delta_time) # elif delta_time < self.frame_time*0.75: # self.dots = len(self.environment.players[0].troops) # print(self.dots) def handle_player(self, player_number, conn, addr): self.server.send( [conn], json.dumps( ( *self.environment.get_terrain_info(), player_number, ), separators=(",", ":"), ), ) while not self.started: time.sleep(0.1) draw_info = json.dumps([[], [], [], []], separators=(",", ":")) while True: if self.ready: draw_info = json.dumps( self.environment.draw_info(player_number), separators=(",", ":") ) self.server.send([conn], draw_info) else: self.server.send([conn], draw_info) player_in = json.loads(self.server.rcv(conn)) if player_in == "close" or self.done == True: self.done = True self.server.close(conn) print("player: ", player_number, " left") break if player_in: if player_in == "pause": self.player_pause_requests[player_number] = True elif player_in == "unpause": self.player_pause_requests[player_number] = False else: self.player_inputs[player_number].extend(player_in[0]) self.player_city_inputs[player_number].extend(player_in[1]) def game_logic(self): city_paths_to_apply = [] for p_num in range(PLAYERS): if self.player_city_inputs[p_num]: city_paths_to_apply.extend(self.player_city_inputs[p_num]) self.player_city_inputs = [[] for i in range(PLAYERS)] self.environment.update_cities(city_paths_to_apply) paths_to_apply = [] for p_num in range(PLAYERS): if self.player_inputs[p_num]: paths_to_apply.extend(self.player_inputs[p_num]) self.player_inputs = [[] for i in range(PLAYERS)] self.ready = False self.environment.update_troops(paths_to_apply) self.ready = True try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() wod_client.py: import simple_socket import pygame import json from constants import * def interp(threshold, a, b): if a == b: return 0.5 t = (threshold - a) / (b - a) return max(0.0, min(1.0, t)) def marching_squares(grid, cell_size, rows, cols, threshold): segments = [] cs = cell_size for j in range(rows): for i in range(cols): c0 = grid[j][i] c3 = grid[j][i + 1] c2 = grid[j + 1][i + 1] c1 = grid[j + 1][i] p_top = interp(threshold, c0, c1) p_right = interp(threshold, c1, c2) p_bottom = interp(threshold, c3, c2) p_left = interp(threshold, c0, c3) x = j * cs y = i * cs p0 = (x + p_top * cs, y) p1 = (x + cs, y + p_right * cs) p2 = (x + p_bottom * cs, y + cs) p3 = (x, y + p_left * cs) idx = 0 if c0 > threshold: idx |= 1 if c1 > threshold: idx |= 2 if c2 > threshold: idx |= 4 if c3 > threshold: idx |= 8 if idx == 0 or idx == 15: pass elif idx == 1: segments.append((p3, p0)) elif idx == 2: segments.append((p0, p1)) elif idx == 3: segments.append((p3, p1)) elif idx == 4: segments.append((p1, p2)) elif idx == 5: segments.append((p3, p0)) segments.append((p1, p2)) elif idx == 6: segments.append((p0, p2)) elif idx == 7: segments.append((p3, p2)) elif idx == 8: segments.append((p2, p3)) elif idx == 9: segments.append((p0, p2)) elif idx == 10: segments.append((p0, p1)) segments.append((p2, p3)) elif idx == 11: segments.append((p1, p2)) elif idx == 12: segments.append((p1, p3)) elif idx == 13: segments.append((p0, p1)) elif idx == 14: segments.append((p3, p0)) return segments def marching_squares_poly(grid, cell_size, rows, cols, threshold): polys = [] cs = cell_size thr = threshold for i in range(rows): for j in range(cols): c0 = grid[i][j] c1 = grid[i][j + 1] c2 = grid[i + 1][j + 1] c3 = grid[i + 1][j] row_pos = i * cs col_pos = j * cs v0 = (row_pos, col_pos) v1 = (row_pos, col_pos + cs) v2 = (row_pos + cs, col_pos + cs) v3 = (row_pos + cs, col_pos) p_top = (row_pos, col_pos + interp(threshold, c0, c1) * cs) p_right = (row_pos + interp(threshold, c1, c2) * cs, col_pos + cs) p_bottom = (row_pos + cs, col_pos + interp(threshold, c3, c2) * cs) p_left = (row_pos + interp(threshold, c0, c3) * cs, col_pos) inside = [c0 > thr, c1 > thr, c2 > thr, c3 > thr] idx = 0 if inside[0]: idx |= 1 if inside[1]: idx |= 2 if inside[2]: idx |= 4 if inside[3]: idx |= 8 if idx == 0: continue if idx == 15: polys.append([v0, v1, v2, v3]) continue pts = { "v0": v0, "v1": v1, "v2": v2, "v3": v3, "p_top": p_top, "p_right": p_right, "p_bottom": p_bottom, "p_left": p_left, } specs = TABLE.get(idx, []) for spec in specs: poly = [pts[name] for name in spec] compact = [] for p in poly: if not compact or (abs(p[0] - compact[-1][0]) > 1e-9 or abs(p[1] - compact[-1][1]) > 1e-9): compact.append(p) if len(compact) >= 3: polys.append(compact) return polys def marching_squares_layers(grid, cell_size, rows, cols, thresholds): layers = [] for thr in thresholds: threshold = thr polys = marching_squares_poly(grid, cell_size, rows, cols, threshold) layers.append(polys) return layers class Game: def __init__(self, title): pygame.init() info_object = pygame.display.Info() desktop_width = info_object.current_w desktop_height = info_object.current_h self.size = (desktop_width - 20, desktop_height - 100) self.factor = min(self.size[0] / WORLD_X, self.size[1] / WORLD_Y) self.screen = pygame.display.set_mode(self.size) pygame.display.set_caption(title) pygame.event.set_allowed( [ pygame.KEYDOWN, pygame.QUIT, pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION, pygame.MOUSEWHEEL, ] ) self.clock = pygame.time.Clock() self.done = False self.zoom_levels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3.5, 4, 6] self.zoom_idx = self.zoom_levels.index(1) self.zoom = self.get_zoom(self.zoom_idx) self.camx, self.camy = 0.0, 0.0 self.panning = False self.pan_start_mouse = (0, 0) self.pan_start_cam = (0.0, 0.0) self.draw_info = None self.player_input = [[], []] self.paths = [] self.drawing_path = False self.city_paths = [] self.drawing_city_path = False self.pause = False self.terrain_by_zoom = {} def run_game(self): ip, port = input("ip\n: "), input("\nport\n: ") print("connecting...") self.client = simple_socket.Client(ip, PORTS[min(99, max(0, int(port)))]) self.client.connect() print("connection successful!") print("drawing terrain...") terrain_grid, forrest_grid, cities, self.player_num = json.loads(self.client.rcv()) self.color = COLORS[self.player_num] layers = marching_squares_layers(terrain_grid, CELL_SIZE, ROWS, COLS, list(TERRAIN_VALUES.values())) layers.append(marching_squares_poly(forrest_grid, CELL_SIZE, ROWS, COLS, THRESHOLD)) for i in range(len(self.zoom_levels)): z = self.get_zoom(i) sw = max(1, int(WORLD_X * z)) sh = max(1, int(WORLD_Y * z)) surf = pygame.Surface((sw, sh), pygame.SRCALPHA) for poly in layers[0]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (0, 220, 255), scaled, 0) for poly in layers[1]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (20, 180, 20), scaled, 0) for poly in layers[2]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (150, 150, 150), scaled, 0) for poly in layers[3]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (100, 100, 100), scaled, 0) for poly in layers[4]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (30, 125, 30), scaled, 0) for position in cities: if position is None: continue cx, cy = int(position[0] * z), int(position[1] * z) pygame.draw.circle(surf, (255, 215, 0), (cx, cy), max(1, int(15 * z))) self.terrain_by_zoom[z] = surf print("terrain drawn! starting game (waiting for other players)...") self.draw_info = json.loads(self.client.rcv()) while not self.done: self.handle_events() self.draw() pygame.display.flip() self.clock.tick(30) self.client.close() pygame.quit() def handle_events(self): if not self.pause: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.MOUSEBUTTONDOWN: if e.button == 3 and not self.drawing_path and not self.drawing_city_path: self.panning = True self.pan_start_mouse = e.pos self.pan_start_cam = (self.camx, self.camy) elif e.button == 4 and not self.drawing_path and not self.drawing_city_path: self.zoom_in_at(e.pos) elif e.button == 5 and not self.drawing_path and not self.drawing_city_path: self.zoom_out_at(e.pos) elif e.button == 1: mx, my = e.pos[0], e.pos[1] troops = self.draw_info[2] r = max(1, int(7 * self.zoom)) r3 = r * 3 best = None best_dist2 = None best_pos = None for pos, color, tid, owner, path, health in troops: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best is None or d2 < best_dist2: best_pos = pos best = tid best_dist2 = d2 if best is not None: self.drawing_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.paths): if id_path[0] == best: to_pop = i if to_pop is not None: self.paths.pop(to_pop) self.paths.append((best, [best_pos])) else: mx, my = e.pos[0], e.pos[1] cities = self.draw_info[3] r = max(1, int(7 * self.zoom)) r3 = r * 3 best_city = None best_dist2 = None best_pos = None for color, pos, cid, path, owner in cities: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best_city is None or d2 < best_dist2: best_pos = pos best_city = cid best_dist2 = d2 if best_city is not None: self.drawing_city_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.city_paths): if id_path[0] == best_city: to_pop = i if to_pop is not None: self.city_paths.pop(to_pop) self.city_paths.append((best_city, [best_pos])) elif e.type == pygame.MOUSEBUTTONUP: if e.button == 3: self.panning = False if e.button == 1: self.drawing_path = False self.drawing_city_path = False elif e.type == pygame.MOUSEMOTION: if self.drawing_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.paths[-1][1].append((wx, wy)) elif self.drawing_city_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.city_paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.city_paths[-1][1].append((wx, wy)) elif self.panning: mx, my = e.pos sx, sy = self.pan_start_mouse dx = (mx) - sx dy = (my) - sy self.camx = self.pan_start_cam[0] - dx / self.zoom self.camy = self.pan_start_cam[1] - dy / self.zoom self.clamp_camera() elif e.type == pygame.MOUSEWHEEL: if not self.drawing_path and not self.drawing_city_path: mx, my = pygame.mouse.get_pos() if e.y > 0: self.zoom_in_at((mx, my)) elif e.y < 0: self.zoom_out_at((mx, my)) elif e.type == pygame.KEYDOWN: if e.key == pygame.K_c: self.paths = [] self.city_paths = [] elif e.key == pygame.K_SPACE: if (not self.drawing_path and not self.drawing_city_path) and (self.paths or self.city_paths): for id, path in self.paths: path.pop(0) for id, path in self.city_paths: path.pop(0) self.player_input[0] = self.paths self.player_input[1] = self.city_paths self.paths = [] self.city_paths = [] elif e.key == pygame.K_p: self.player_input = "pause" self.pause = True else: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.KEYDOWN: if e.key == pygame.K_p: self.player_input = "unpause" self.pause = False self.client.send(json.dumps(self.player_input, separators=(",", ":"))) self.player_input = [[], []] def zoom_in_at(self, screen_pos): if self.zoom_idx < len(self.zoom_levels) - 1: self.set_zoom_index(self.zoom_idx + 1, screen_pos) def zoom_out_at(self, screen_pos): if self.zoom_idx > 0: self.set_zoom_index(self.zoom_idx - 1, screen_pos) def get_zoom(self, zoom_idx): return self.zoom_levels[zoom_idx] * self.factor def set_zoom_index(self, new_idx, screen_pos): old_zoom = self.zoom new_zoom = self.get_zoom(new_idx) sx, sy = screen_pos world_x = self.camx + sx / old_zoom world_y = self.camy + sy / old_zoom self.zoom_idx = new_idx self.zoom = new_zoom self.camx = world_x - sx / new_zoom self.camy = world_y - sy / new_zoom self.clamp_camera() def clamp_camera(self): max_camx = max(0.0, WORLD_X - (self.size[0] / self.zoom)) max_camy = max(0.0, WORLD_Y - (self.size[1] / self.zoom)) if self.camx < 0.0: self.camx = 0.0 if self.camy < 0.0: self.camy = 0.0 if self.camx > max_camx: self.camx = max_camx if self.camy > max_camy: self.camy = max_camy def draw(self): self.screen.fill((255, 255, 255)) vision_grid, border_grid, troops, cities = self.draw_info = json.loads( self.client.rcv() ) z = self.zoom terrain_surf = self.terrain_by_zoom[z] offset_x = int(-self.camx * z) offset_y = int(-self.camy * z) self.screen.blit(terrain_surf, (offset_x, offset_y)) dyn_w = max(1, int(WORLD_X * z)) dyn_h = max(1, int(WORLD_Y * z)) dynamic = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) fog = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) paths_to_draw = [] for color, position, cid, path, owner in cities: if path and owner == self.player_num: path.insert(0, position) paths_to_draw.append(path) if color is not None: px = int(position[0] * z) py = int(position[1] * z) pole_bottom = (px, py) pole_top = (px, int(py - 30 * z)) pygame.draw.line(dynamic, (80, 80, 80), pole_bottom, pole_top, max(1, int(3 * z))) flag_color = tuple(color) if isinstance(color, (list, tuple)) else color fw, fh = int(20 * z), int(14 * z) p1 = (pole_top[0], pole_top[1]) p2 = (pole_top[0] + fw, pole_top[1] + fh // 2) p3 = (pole_top[0], pole_top[1] + fh) pygame.draw.polygon(dynamic, flag_color, [p1, p2, p3]) pygame.draw.polygon(dynamic, (0, 0, 0), [p1, p2, p3], max(1, int(1 * z))) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (240, 180, 0), (px, py), (px2, py2), max(1, int(4 * z))) paths_to_draw = [] tids = [tid for tid, path in self.paths] for pos, color, tid, owner, path, health in troops: px = int(pos[0] * z) py = int(pos[1] * z) r = max(1, int(7 * z)) rgb = color if tid in tids: factor = 0.5 rgb = [max(0, min(255, int(x * factor))) for x in color] if path and owner == self.player_num: path.insert(0, pos) paths_to_draw.append(path) pygame.draw.rect(dynamic, (0, 255, 0), pygame.rect.Rect(px-r, (py-r)-max(1, int(3 * z)), (r*2)*(health/100), max(1, int(3 * z)))) pygame.draw.circle(dynamic, rgb, (px, py), r) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, self.color, (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.city_paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for a, b in marching_squares(border_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): ax = int(a[0] * z) ay = int(a[1] * z) bx = int(b[0] * z) by = int(b[1] * z) pygame.draw.line(fog, (0, 0, 0), (ax, ay), (bx, by), max(1, int(3 * z))) for poly in marching_squares_poly(vision_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(fog, (0, 0, 0, 150), scaled, 0) if self.pause: font = pygame.font.SysFont(None, 48) text_surface = font.render("Pause", False, (0, 0, 0)) fog.blit(text_surface, (10, 10)) self.screen.blit(dynamic, (offset_x, offset_y)) self.screen.blit(fog, (offset_x, offset_y)) game_play = Game("WAR OF DOTS") game_play.run_game() simple_socket.py: import socket ####### https://docs.python.org/3/library/socket.html#socket.socket.sendfile ####### #socket.gethostbyname(str(socket.gethostname()))# HEADER, FORMAT = 64, "utf-8" class Client: def __init__(self, servip, port): self.servip = servip self.port = port def connect(self): self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) ADDR = (self.servip, self.port) self.client.connect(ADDR) def send(self, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) self.client.sendall(send_length) self.client.sendall(message) def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self): self.client.close() class Server: def __init__(self, ip, port): self.ip = ip self.port = port def start(self): self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) ADDR = (self.ip, self.port) self.server.bind(ADDR) self.conns = [] def lsn(self, conns=0): if conns > 0: self.server.listen(conns) else: self.server.listen() def accept(self): conn, addr = self.server.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.conns.append(conn) return conn, addr def send(self, conns, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) for conn in conns: conn.sendall(send_length) conn.sendall(message) def rcv(self, conn): msg_length = conn.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = conn.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self, conn): conn.close() self.conns.remove(conn) constants.py: CELL_SIZE = 20 SIZE = (1280, 700) WORLD_X, WORLD_Y = SIZE ROWS = int(SIZE[0]//CELL_SIZE) COLS = int(SIZE[1]//CELL_SIZE) TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } TROOP_R = 7 THRESHOLD = 0.5 PLAYERS = 2 COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] TABLE = { 1: [["v0", "p_top", "p_left"]], 2: [["v1", "p_right", "p_top"]], 3: [["p_left", "v0", "v1", "p_right"]], 4: [["v2", "p_bottom", "p_right"]], 5: [["v0", "p_top", "p_left"], ["v2", "p_right", "p_bottom"]], 6: [["p_top", "v1", "v2", "p_bottom"]], 7: [["p_left", "v0", "v1", "v2", "p_bottom"]], 8: [["v3", "p_left", "p_bottom"]], 9: [["p_top", "v0", "v3", "p_bottom"]], 10: [["p_top", "v1", "p_right"], ["p_bottom", "v3", "p_left"]], 11: [["v0", "v1", "p_right", "p_bottom", "v3"]], 12: [["p_right", "v2", "v3", "p_left"]], 13: [["v0", "p_top", "p_right", "v2", "v3"]], 14: [["v1", "v2", "v3", "p_left", "p_top"]], } PORTS = [i for i in range(1200, 1300)] Question: Ultimately I am looking for performance because I want to increase map size for more players and not lag the server or clients. Any other improvements and bug fixes (I hope there are none!) are welcome. Gameplay suggestions are especially welcome if you actually play it! EDIT: Also I have found a few bugs; the zoom controls are bugged and the bottom left and top right city selections were wrong so ignore them for now pls. oh! and the brush apply function doesn't go to the bottom and right edges. Bounties: I will be placing bounties, to reward the efforts people go to to review this code beyond the accepted answer. pythonperformancepygamebattle-simulation Share Follow edited Feb 18 at 0:41 asked Feb 3 at 10:40 coder's user avatar coder 31911 silver badge2424 bronze badges 2 You really made me watch trailer/videos about war of dots and you image seems quite like the original. I'd hope to find time for this question, it really looks fun. – Thingamabobs CommentedFeb 4 at 4:16 1 Not exactly the feedback you were looking for, but this very much looks like a golf simulator. We see the holes (only 10 of them, so I guess a short course), the green, the rough, the water traps, the locations of the balls people have been hitting, and I guess a dirt trap? I can't unsee it. Best of luck on the game. – Seth Robertson CommentedFeb 5 at 0:18 @SethRobertson haha thanks, i love how it turned out visually and hope it clearly displays the game state. – coder CommentedFeb 5 at 1:07 Add a comment 4 Answers Sorted by: Highest score (default) 3 +50 Wow, what a great game! Thank you. I still need to diagnose why pressing "P" to pause causes fatal stacktrace. initial rendezvous Using input() is kind of OK. But you should definitely let me specify "127.0.0.1" and port "0" in sys.argv, with perhaps the host defaulting to localhost or to the value of an optional env var. The whole business of discussing "port 0" with the user, and then biasing it by 1200 during the bind() call, is needlessly confusing. Just tell the user that ports like 1200 and 1201 will be used. Better yet, make it automatic. The user specifies a hostname, on which server is already running, and the client makes a connection and prints out how many users are already on that server. If zero existing players, then make me 0, and so on. I was a little surprised that all players can connect using same port number, or using distinct port numbers, and in both situations the game works fine. It feels like we're exposing more complexity than strictly necessary. diagnostic Personally, I found getting a ConnectionRefusedError very informative. But you might want a try / except which replaces that with some advice about needing to run the server before running any clients. import PEP-8 asks for three sections of imports, each alphabetically sorted. import json import perlin_noise import math import simple_socket Use isort to accomplish that. Why does it matter? I wound up doing uv add simple_socket and pulling in simple-socket 0.0.10 from pypi, before I eventually noticed your simple_socket.py module. Grouping those libraries tells the Reader where the library came from, and where to go looking for the docs. In your repo you should definitely add a Makefile or similar bash script that shows how you produce a .exe when cutting a release. Consider publishing binaries on a different site, or at least in a different repo, as git really wants to diff text edits in order to avoid bloat. Binaries are incompressible, so a lot of git history can accumulate in short order. You should publish a pyproject.toml file that shows how collaborators should install dependencies, and how you publish to pypi. wildcard from constants import * Uggh! Please don't. Remove the star, note the missing symbols, and use your IDE's auto-complete to fill them in explicitly. As a Reader, when I see star I think "all bets are off", because if I don't recognize an unfamiliar symbol I won't know if it should have matched the wildcard. Even worse when more than one import is wildcarded. Also, I can't grep to see where something came from. tuple unpack In xy_to_dir_dis() we see atan2(xy[1], xy[0]). Please refrain from using those cryptic [1], [0] subscripts which are unrelated to the problem at hand. Prefer to speak plainly: x, y = xy Then you can use a straightforward atan2(y, x) call. Also, 0 - x is weird, where unary -x would suffice. And why are we negating in the first place, given that the squaring will make it positive? Consider introducing Polar and Cartesian coordinate classes, or borrowing them from an existing pypi library or from the builtin Complex type. Consider keeping the internal representation as radians. pointers self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] Wow, that's a lot of 64-bit pointers to (double prec.) float objects. Prefer array, for a more compact representation with less pointer indirection. If nothing else, smaller footprint is more cache friendly. Better still, use an NDArray, if you're willing to take a dep on NumPy. Consider whether 32-bit single precision floats would be adequate, here. meaningful identifier def get_grid_value(self, x, y): ... val = ( ... This is indeed a getter. But value & val are on the vague side; I would love to know the meaning of the returned number. And I'm not too sure we're properly using the word grid here. It feels more like there's a continuous battle field, occasionally punctuated by grid points, and this interpolation helper is performing get_field_potential_from_grid(). (I imagine that each Red and Blue unit on the field imposes a plus or minus potential, and we're reporting the net potential.) A """docstring""" would have gone a long way toward illuminating Author's Intent for what this helper does. edge case Sorry, but when reading x2, ... = min(x1 + 1, ROWS), ..., I'm not quite sure what to make of it. We already have that x1 is an integer. So either x2 is one bigger, or when at the ROWS limit x2 is identical to x1, so "interpolate" works out to "just return the coarse grid value". And we might be at the limit in one direction but not the other. Seems like it's worth tacking on a sentence at the end of the docstring, or at least add a # comment. The docstring should additionally cite https://en.wikipedia.org/wiki/Marching_squares invariants In Brush.apply, I'm a little surprised that we need to verify radius is positive -- not sure what would shrink it. And we could have just initialized at 40.0, without need for a float() call. enum Helpers like get_terrain_name() make it look like we really want to store names and speed values in an Enum. We could then put such mapping helpers within that class. @dataclass get_terrain_info() looks like it wants to return a TerrainInfo dataclass. helpers As you commented, update_troops() clearly needs to break out several helper functions. The closest_city attribute looks like it could be memoized, since cities don't move. (Not sure why they have a path.) For each player and each discretized grid point, compute and store the closest city. Then a troop can use int() to get its grid point, and look up the closest city without looping. There's a lot of magic numbers in there. Am I worried that they're not given names? No, not really. It does worry me that they don't show up in the player documentation, and as a player it's hard for me to visualize those interactions on-screen. I can't tell if a unit I just moved is now in or out of range for one or another of those special cases. In the Game constructor, 1200 certainly deserves a name with PORT in it. mutable defaults def __init__(self, ..., path=None): ... self.path = path if path is not None else [] Thank you for avoiding a mutable Troop default. The usual idiom here is slightly more compact: self.path = path or [] In the general case we'd need what you wrote, if e.g. path could take on a "falsey" value like 0.0. But here it's only the len() of a list that matters. It seems odd you're caching id(self), since you could cheaply ask for it any time you need it. number of players I propose that there's six players, all the time. But initially they're all inactive. And as TCP clients join, they claim the first inactive player. This makes joining a game after some delay more flexible, and reduces the interactive questions we have to ask up front. a boolean variable is already a boolean ... or self.done == True Prefer a simple ... or self.done. Also, looking at {ready, started, done}, perhaps we have more state variables than needed? For example, the "sleep 100 msec till self.started" loop could perhaps be removed if we deferred creating Players and threads until all had joined. Also, I'm not quite understanding self.done. When Blue defeats all Red cities and units, the game continues to play, rather than declaring victory. main guard wod_server.py contains quite a lot of code, and that is fine. But we don't have the traditional if __name__ == "__main__": guard, so automated unit tests cannot safely import this module without undesired side effects, and that is a problem. contract The wod_client interp() function really needs a docstring. What promises is it making to the caller? And marching_squares() needs either a docstring or at least some comments, perhaps a literature citation. Also, in handle_events() and in draw() we need to break out some helpers. "values" TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } Yes, each of those four is a numeric "value". But tell us what those numbers mean. Do they relate to vision? To mobility? COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] Consider defining some Enums or some MANIFEST_CONSTANTS, and then put named colors into that list. gameplay grid markings Distance between units plays an important role in this game. Consider superimposing a purely cosmetic square or hex grid upon the display, to help folks visually judge relative distances. Paper maps often have a "12 miles to the inch" type of legend in the corner, with some black & white markings that let you put your thumb first here, then there, and say it's so many miles to destination. If there's no visible grid, consider putting some standard size markings in a corner legend. within range I have some idea of which units are interacting (killing) which other units. But it's not completely obvious, and I was surprised at how many special cases the server code handles. I feel like there's enough information available to the client module that it could choose to render orange lighting zaps coming from an attacking unit, that sort of thing. To support this, we would need a common library module which both server and client import, describing the damage model. There's two things I care about for a unit: health (damage), and rate of damage (or rate of healing) With the current game display, I have a hard time knowing which units in the fray are actually interacting, and if I have pulled a damaged unit "far enough" away from the melee. There is room to reveal derivative of health, IDK maybe with a simple left/right arrow to show it is dwindling or increasing. random damage After Blue defeats all Red cities and troops, I still observe Blue deaths, and respawns at cities. I cannot explain the damage / death events. update speed We already track FPS. I wouldn't mind seeing a one-line speed report that appears once per minute. It could mention how much usermode CPU we've been using recently. I would love to know about episodes where we asked for an unusually small sleep() time. There's no automated unittest for a server update cycle, and that makes it hard to profile the server code to see where the hot spots are. A test could include a scaling parameter, so we compare elapsed time on small vs. large maps or army sizes. It is often the case that "most" troops lack an active path. During profiling I would want to know if a "keep the status quo" approach is a good fit for such idle troops, so they're updated less often. The marching squares (visibility) calculations could perhaps be scheduled less often than troop updates are, without being visually jarring. Every nth frame might suffice. summary score Maybe display total number of per-player troops? (Or does that only become of interest once a player loses all cities?) Total number of "deployed" (out-of-city) troops? Total deaths? Maybe display the current aggregate damage (or "heal") rate for each player? Maybe a mouse-over on a troop would reveal its damage rate? Could be a tooltip near the pointer, but probably better to bury it in a corner display near the legend, so it's less distracting. Share Follow edited Feb 7 at 16:05 answered Feb 6 at 21:36 J_H's user avatar J_H 46k33 gold badges4141 silver badges167167 bronze badges is the overhead of converting ndarray to list and back for json worth using ndarrays? (great answer btw +1 and likely +50) – coder CommentedFeb 7 at 0:37 1 "overhead of converting ndarray to list and back for json" Surely we don't do that 45 times per second, right? In any event, nobody cares what one person's opinion might be. It sounds like we want to Extract Helper so the pygame loop can call into the same update() that a unit test or timeit() calls into. And then "worth it" is just a matter of comparing one elapsed time against another. // I happened to be playing Blue. I'm happy to play Red until I witness Blue's extinction, and I imagine I will see a similar "Red troops die / respawn even with no enemy troops alive" effect. – J_H CommentedFeb 7 at 1:35 do what you like with testing its your time/answer || ill look into sending numpy info, it probably will end up worth doing for performance because the brush apply could be improved with numpy for sure, the main performance issue it seems rn 1269497 72.511 0.000 115.930 0.000 wod_server.py:58(apply) – coder CommentedFeb 7 at 1:46 1 Ok, issue 2 shows the relevant screenshot for "unexpected damage". Automated unit tests would help with narrowing this down. – J_H CommentedFeb 7 at 4:26 Add a comment 7 +100 UX When I run the wod_server.py code, I get this prompt: Enter number of players (2-6): That is easy to understand. Then I get this prompt: Enter port to use (0 - 99): But, I am not sure what the numbers mean. The prompt could be a little more specific. It would be better to print a few lines of information before the prompts, giving the user more context. Briefly describe the game to someone who has never played it. You could also offer an option to bypass the introduction when you run the code (for users who have already played it). The code also appears hung for me at this line of output: waiting for players... I don't know how to proceed to actually play the game. Perhaps the code is waiting for someone to run the wod_client.py code. If that is the case, it would be good to explicitly state it. When I run wod_client.py, I get a GUI window, but it is just a black screen, and I don't know what to do. Documentation The PEP 8 style guide recommends adding docstrings for classes and functions. Consider using type hints to describe input and return types of the functions to make the code even more self-documenting. Comments Comments are intended to describe the code, not question it: def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? That comment should be deleted. Simpler I believe this line: (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 can be simplified as: xy[0] ** 2 + xy[1] ** 2 Give it a try. Also, instead of math.sqrt, you can simplify further using hypot: def xy_to_dir_dis(xy): return math.degrees(math.atan2(xy[1], xy[0])), math.hypot(xy[0], xy[1]) hypot can also be used in the elevation_bias function. When I see lines like: self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] I wonder if the code could be simplified by using numpy, which may also have performance benefits. Main guard It is customary to add a "main" guard at the end of the code in file wod_server.py: if __name__ == '__main__': try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() Naming PEP 8 only recommends all caps for constants, not variables. In wod_server.py, PLAYERS would be players. Tools You could run code development tools to automatically find some style issues with your code. ruff identifies things like this in wod_server.py: E714 [*] Test for object identity should be `is not` | | self.city_border_brush.apply(player.border, city.position, 1.0) | for other_player in self.players: | if not player is other_player: | ^^^^^^^^^^^^^^^^^^^^^^ E714 | for city in self.cities: | if city.owner is other_player: | = help: Convert to `is not` Share Follow answered Feb 3 at 12:50 toolic's user avatar toolic 21.9k66 gold badges3232 silver badges270270 bronze badges Add a comment 5 Context managers Both your Server and Client classes in simple_socket.py have close functions. I can't help but think that maybe these classes should implement the behaviors of a context manager so you don't necessarily have to explicitly manage this. Boolean comparisons Don't do this. self.done == True Simpler: self.done Early exits In simple_socket.py you have a function: def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" I can't help but think this reads better with an early return in the event msg_length is false. def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if not msg_length: return "" msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) You also never use msg_length except after converting it to an int so rather than repeating yourself: def rcv(self): msg_length = int(self.client.recv(HEADER).decode(FORMAT)) total_received = 0 if not msg_length: return "" msg = [] while total_received < msg_length: data = self.client.recv(msg_length) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) DRY In the following I see a lot of repetition in setting self.players. You may wish to create a list of players once and then assign slices of that list to self.players depending on the value of PLAYERS. if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] Share Follow answered Feb 3 at 15:54 Chris's user avatar Chris 18.2k11 gold badge1414 silver badges9292 bronze badges Add a comment 5 I have not attempted to run this program, so these are just some random thoughts after performing a cursory review. Enum First of all, you deal with tuples a lot, so using namedtuple would make sense at some places. Enums are underutilized as well. For example, instead of: COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] I would suggest this approach: from enum import Enum class Color(Enum): RED = 255, 0, 0 GREEN = 0, 255, 0 BLUE = 0, 0, 255 WHITE = 255, 255, 255 BLACK = 0, 0, 0 print(Color.BLUE) Then you can use expressive color names, that make the code immediately more descriptive. In fact, the parentheses are not needed here, you can remove them. I have not bothered to introduce a full RGB class, this setup is sufficient for your purpose. You could also use namedtuple for coordinates. As shown in another question from collections import namedtuple Point = namedtuple('Point', 'x y', defaults=[0, 0]) I regret the lack of comments. Since the code is fairly long, and without being privy to the game, the flow is not easy to follow without testing the program and studying the rules of the game. From time to time, it would be helpful to comment blocks of code, to explain what you are doing at this specific point and why. Naming Function names are not always intuitive. For example, update_cities does not tell me what exactly we are doing here. Other names like xy_to_dir_dis are somewhat cryptic. Don't be afraid to use longer names, there is no bonus for abbreviating things. To take a random example: def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) You are assigning a new variable cs, which takes the value of CELL_SIZE. Just for the sake of abbreviating. That only obfuscates the code. Likewise, self.radius is much more expressive than just r, which could mean anything. And you could explicitly cast self.radius to float in your __init__ method if that matters. But many variable names are very short, which does not facilitate comprehension, and can even be a cause of bugs in my opinion. Class Some classes like Troop, City, Player should be made @dataclass to reduce boilerplate a little bit. In class Environment, the assignment of self.players is quite dense and there is repeat code. I think it would be interesting to explore dataclass and especially the default_factory method. Suggested article which I found helpful: Python Data Classes: A Comprehensive Tutorial Share Follow answered Feb 3 at 17:37 Kate's user avatar Kate 11k1010 silver badges3030 bronze badges Add a comment You must log in to answer this question. Start asking to get answers Find the answer to your question by asking. Explore related questions pythonperformancepygamebattle-simulation See similar questions with these tags. The Overflow Blog Your LLM issues are really data issues Welcome to the “find out” stage of AI Report this ad Report this ad Linked 5 YouTube Downloader implementation using CustomTkinter and Pytubefix 9 Maze Solver in Python inspired by Micro-Mouse Competition Logic 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Related 2 War card game simulator 12 Script for a Civil War game 9 Simple top down shooter game 3 Python 4 Players Snake Game 6 Python/Pygame Fighting Game 5 Simple Python Pygame Game 2 Card game of war using pygame module 5 Very simple Flappy 'Bird' game - First project in Python 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Hot Network Questions Which type of barrel adjuster is missing here? First book of series might leave a bad impression; rewrite or not? What precedent did the 2 Live Crew case actually set? How can we obtain a smoother sphere when cutting it with a plane and moving the cut portion? Non-quasi-separated perfectoid spaces more hot questions Question feed Code Review Tour Help Chat Contact Feedback Company Stack Overflow Stack Internal Stack Data Licensing Stack Ads About Press Legal Privacy Policy Terms of Service Your Privacy Choices Cookie Policy Stack Exchange Network Technology Culture & recreation Life & arts Science Professional Business API Data Blog Facebook Twitter LinkedIn Instagram Site design / logo © 2026 Stack Exchange Inc; user contributions licensed under CC BY-SA . rev 2026.4.23.42490 import json import perlin_noise import math import simple_socket import socket import time import threading import random from constants import * def dir_dis_to_xy(direction, distance): return ( (distance * math.cos(math.radians(direction))), (distance * math.sin(math.radians(direction))), ) def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? return math.degrees(math.atan2(xy[1], xy[0])), math.sqrt( (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 ) class MarchingSquares: def __init__(self): self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] def set_grid(self, new_grid): self.grid = new_grid def get_grid_value(self, x, y): x1, y1 = int(x), int(y) x2, y2 = min(x1 + 1, ROWS), min(y1 + 1, COLS) dx, dy = x - x1, y - y1 p11 = self.grid[x1][y1] p21 = self.grid[x2][y1] p12 = self.grid[x1][y2] p22 = self.grid[x2][y2] val = ( p11 * (1 - dx) * (1 - dy) + p21 * dx * (1 - dy) + p12 * (1 - dx) * dy + p22 * dx * dy ) return val class Brush: def __init__(self, radius=40, strength=1.0, falloff=1.0): self.radius = radius self.strength = strength self.falloff = falloff def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) inv_r = 1.0 / r grid = marching_squares.grid strength = self.strength falloff = self.falloff for j in range(row_start, row_end): px = j * cs dx_sq = (px - mx) ** 2 row = grid[j] for i in range(col_start, col_end): py = i * cs dy = py - my dist_sq = dy * dy + dx_sq if dist_sq <= r * r: dist = math.sqrt(dist_sq) t = dist * inv_r weight = strength + t * (falloff - strength) old = row[i] row[i] = max(0.0, min(1.0, old + (target_value - old) * weight)) class Environment: def __init__(self): self.terrain_speeds = { "water": 0.6, "forest": 0.8, "plains": 1, "hill": 0.7, "mountain": 3, } self.terrain_attacks = { "water": 0.5, "forest": 0.75, "plains": 1, "hill": 1.5, "mountain": 0, } self.terrain_marching = MarchingSquares() self.forest_marching = MarchingSquares() self.cities = [] self.default_vision = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] for y in range(COLS + 1): for x in range(ROWS + 1): self.default_vision[x][y] = 0.0 self.generate_terrain() self.generate_default_vision() # 2 players left and right most cities # 3 players left-bottom, top, right-bottom # 4 players left-bottom, top-left, top-right, right-bottom # 5 players left-bottom, top-left, middle, top-right, right-bottom # 6 players left-bottom, top-left, middle-left, middle-right, top-right, right-bottom left_bottom_city = min(self.cities, key=lambda c: c.position[0] + c.position[1]) top_left_city = min(self.cities, key=lambda c: c.position[0] - c.position[1]) middle_top_city = min( self.cities, key=lambda c: (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5) + c.position[1], ) middle_bottom_city = max( self.cities, key=lambda c: c.position[1] - (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5), ) top_right_city = max(self.cities, key=lambda c: c.position[0] - c.position[1]) right_bottom_city = max( self.cities, key=lambda c: c.position[0] + c.position[1] ) left_city = min(self.cities, key=lambda c: c.position[0]) right_city = max(self.cities, key=lambda c: c.position[0]) top_city = max(self.cities, key=lambda c: c.position[1]) middle_city = min( self.cities, key=lambda c: abs(c.position[0] - (ROWS * CELL_SIZE) / 2) + abs(c.position[1] - (COLS * CELL_SIZE) / 2), ) if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] self.vision_brush = Brush(75, 1, 0) self.city_vision_brush = Brush(175, 1, 0) self.border_brush = Brush(40, 0.05, 0) self.city_border_brush = Brush(80, 0.05, 0) self.players_in_cities = [[] for _ in self.cities] def generate_terrain(self): def elevation_bias(x, y): cx = ROWS / 2 cy = COLS / 2 dx = abs(x - cx) dy = abs(y - cy) dist = math.sqrt((dx) ** 2 + (dy) ** 2) max_dist = math.sqrt((cx) ** 2 + (cy) ** 2) return 1.0 - (dist / max_dist) noise = perlin_noise.PerlinNoise(octaves=3) for y in range(COLS + 1): for x in range(ROWS + 1): value = max( 0, min(1, ((noise([x / 25, y / 25])) - 0.2) + (elevation_bias(x, y))), ) self.terrain_marching.grid[x][y] = value forest_noise = perlin_noise.PerlinNoise(octaves=1.1) for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] value = (min(0.6, forest_noise([x / 30, y / 30])) * 2.0) + 0.3 plains_diff = max(0, (TERRAIN_VALUES["plains"] + 0.1) - terrain_value) hill_diff = max(0, terrain_value - (TERRAIN_VALUES["hill"] - 0.1)) self.forest_marching.grid[x][y] = ( value - (plains_diff * 10) ) - hill_diff * 10 def within_edges(cx, cy): edge_margin = int(1) return ( cx >= edge_margin and cx <= ROWS - edge_margin and cy >= edge_margin and cy <= COLS - edge_margin ) tries = 0 distance = 15 while True: cx = random.randint(0, ROWS) cy = random.randint(0, COLS) terrain_value = self.terrain_marching.grid[cx][cy] if ( ( terrain_value > TERRAIN_VALUES["plains"] and terrain_value < TERRAIN_VALUES["hill"] ) and all( abs(cx * CELL_SIZE - city.position[0]) + abs(cy * CELL_SIZE - city.position[1]) >= CELL_SIZE * distance for city in self.cities ) and within_edges(cx, cy) and self.forest_marching.grid[cx][cy] < THRESHOLD ): px = cx * CELL_SIZE py = cy * CELL_SIZE self.cities.append(City((px, py))) distance = 15 if len(self.cities) >= 10: break tries += 1 if tries >= 100: distance = max(2, distance - 2) tries = 0 def generate_default_vision(self): for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] forest_value = self.forest_marching.grid[x][y] self.default_vision[x][y] = 0.35 + ( max(min((((terrain_value + 0.1) / 1) + 0.2), 1), 0.2) + (0.8 if forest_value > 0.6 else 0.0) ) def draw_info(self, player): ply = self.players[player] vision_grid = ply.vision.grid border_grid = ply.border.grid troops = [] cities = [ ( c.owner.color if c.owner is not None else None, c.position, c.id, c.path, self.players.index(c.owner) if c.owner is not None else -1, ) for c in self.cities ] for troop in [t for p in self.players for t in p.troops]: ply = self.players[player] vision = ply.vision px, py = troop.position gx = px / CELL_SIZE gy = py / CELL_SIZE gx = max(0, min(ROWS, gx)) gy = max(0, min(COLS, gy)) if vision.get_grid_value(gx, gy) < THRESHOLD: troops.append( ( troop.position, troop.owner.color, troop.id, self.players.index(troop.owner), troop.path, troop.health, ) ) return vision_grid, border_grid, troops, cities def get_terrain_info(self): return ( self.terrain_marching.grid, self.forest_marching.grid, [c.position for c in self.cities], ) def get_terrain_name(self, value, fvalue): if fvalue > THRESHOLD: return "forest" for name, v in reversed(TERRAIN_VALUES.items()): if value > v: return name def update_troops(self, paths_to_apply): # split into more functions ? self.players_in_cities = [[] for _ in self.cities] troop_ids = [info[0] for info in paths_to_apply] troop_paths = [info[1] for info in paths_to_apply] for player in self.players: player.vision.grid = [row[:] for row in self.default_vision] for city in self.cities: if city.owner is player: self.city_vision_brush.apply(player.vision, city.position, 0) self.city_border_brush.apply(player.border, city.position, 1.0) for other_player in self.players: if not player is other_player: for city in self.cities: if city.owner is other_player: self.city_border_brush.apply( player.border, city.position, 0.0 ) to_remove = [] for troop in player.troops: if troop.health <= 0: to_remove.append(troop) continue try: tidx = troop_ids.index(id(troop)) troop.path = troop_paths[tidx] except ValueError: pass old_pos = troop.position owned = [city.position for city in self.cities if city.owner is player] if owned: closest_city = min( owned, key=lambda x: xy_to_dir_dis( ((old_pos[0] - x[0]), (old_pos[1] - x[1])) ), ) city_dir, city_dist = xy_to_dir_dis( ((old_pos[0] - closest_city[0]), (old_pos[1] - closest_city[1])) ) sample_points = [ dir_dis_to_xy(city_dir, dist * 20) for dist in range(int(city_dist // 20)) ] border_avg = 0 if sample_points: border_avgs = [] for other_player in self.players: if other_player is not player: border_avgs.append( sum( [ other_player.border.get_grid_value( (closest_city[0] + s_p[0]) / CELL_SIZE, (closest_city[1] + s_p[1]) / CELL_SIZE, ) for s_p in sample_points ] ) / len(sample_points) ) border_avg = sum(border_avgs) / len(border_avgs) dist_penal = max(((city_dist + 250) / 1000), 0.5) healing_power = (1 - (border_avg / 2)) - dist_penal else: healing_power = -0.5 troop.health += healing_power / 25 if troop.health > 100: troop.health = 100 enemies_in_range = [] gx = old_pos[0] / CELL_SIZE gy = old_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) on_terrain = self.get_terrain_name(terrain, forest) if troop.path: target = troop.path[0] terrain_speed = self.terrain_speeds[on_terrain] dir, distance = xy_to_dir_dis( (target[0] - old_pos[0], target[1] - old_pos[1]) ) distance = terrain_speed * 0.1 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) new_pos = (old_pos[0] + new_off_x, old_pos[1] + new_off_y) for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 14: distance = 14 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain dir, distance = xy_to_dir_dis( (target[0] - troop.position[0], target[1] - troop.position[1]) ) if distance < (terrain_speed * 2): troop.path.pop(0) else: new_pos = old_pos for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 15: distance += 0.025 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain if enemies_in_range: attack_power = self.terrain_attacks[on_terrain] / 25 closest = min(enemies_in_range, key=lambda x: x[1]) closest[0].health -= attack_power if on_terrain == "hill": self.city_vision_brush.apply(player.vision, troop.position, 0) else: self.vision_brush.apply(player.vision, troop.position, 0) self.border_brush.apply(player.border, troop.position, 1.0) for i, city in enumerate(self.cities): cx, cy = city.position tx, ty = troop.position dir, dist = xy_to_dir_dis((tx - cx, ty - cy)) if dist < 15: self.players_in_cities[i].append(player) break to_remove.reverse() for t in to_remove: player.troops.remove(t) def update_cities(self, paths_to_apply): city_ids = [info[0] for info in paths_to_apply] city_paths = [info[1] for info in paths_to_apply] for i, city in enumerate(self.cities): try: cidx = city_ids.index(id(city)) city.path = city_paths[cidx] except ValueError: pass cx, cy = city.position last_owner = city.owner if len(self.players_in_cities[i]) == 1: city.owner = self.players_in_cities[i][0] if last_owner is not city.owner: city.timer = 0 city.path = [] if city.owner is not None: city.timer += 1 t_per_c = len(city.owner.troops) / len( [c for c in self.cities if c.owner == city.owner] ) if city.timer >= 45 * (30 * t_per_c) and t_per_c < 10: city.owner.troops.append( Troop( ( cx + random.randrange(-6, 6), cy + random.randrange(-6, 6), ), city.owner, city.path.copy(), ) ) city.timer = 0 class Troop: def __init__(self, position, owner, path=None): self.position = position self.health = 100 self.path = path if path is not None else [] self.owner = owner self.id = id(self) class City: def __init__(self, position): self.position = position self.timer = 0 self.owner = None self.id = id(self) self.path = [] class Player: def __init__(self, start_pos, color, environment): self.start_pos = start_pos self.color = color self.troops = [Troop(self.start_pos, self)] self.border = MarchingSquares() self.vision = MarchingSquares() self.vision.grid = [row[:] for row in environment.default_vision] class Game: def __init__(self): self.FPS = 45 self.last_time = time.perf_counter() self.frame_time = 1 / self.FPS self.done = False self.server = simple_socket.Server( socket.gethostbyname(str(socket.gethostname())), 1200 ) self.environment = Environment() self.player_inputs = [[] for i in range(PLAYERS)] self.player_city_inputs = [[] for i in range(PLAYERS)] self.player_pause_requests = [False for i in range(PLAYERS)] self.started = False def run_game(self): self.ready = True try: port = int(input("Enter port to use (0 - 99): ")) self.server.port = PORTS[max(0, min(99, port))] except ValueError: pass print("ip: ", self.server.ip, ", port: ", self.server.port) print("starting server...") self.server.start() print("waiting for players...") self.server.lsn(conns=PLAYERS) for player_num in range(PLAYERS): conn, addr = self.server.accept() player_thread = threading.Thread( target=self.handle_player, args=(player_num, conn, addr) ) player_thread.start() print("player: ", player_num, " connected") print("All players connected, starting game!") self.started = True while not self.done: if not all(self.player_pause_requests): self.game_logic() current_time = time.perf_counter() delta_time = current_time - self.last_time self.last_time = current_time if delta_time < self.frame_time: time.sleep(self.frame_time - delta_time) # elif delta_time < self.frame_time*0.75: # self.dots = len(self.environment.players[0].troops) # print(self.dots) def handle_player(self, player_number, conn, addr): self.server.send( [conn], json.dumps( ( *self.environment.get_terrain_info(), player_number, ), separators=(",", ":"), ), ) while not self.started: time.sleep(0.1) draw_info = json.dumps([[], [], [], []], separators=(",", ":")) while True: if self.ready: draw_info = json.dumps( self.environment.draw_info(player_number), separators=(",", ":") ) self.server.send([conn], draw_info) else: self.server.send([conn], draw_info) player_in = json.loads(self.server.rcv(conn)) if player_in == "close" or self.done == True: self.done = True self.server.close(conn) print("player: ", player_number, " left") break if player_in: if player_in == "pause": self.player_pause_requests[player_number] = True elif player_in == "unpause": self.player_pause_requests[player_number] = False else: self.player_inputs[player_number].extend(player_in[0]) self.player_city_inputs[player_number].extend(player_in[1]) def game_logic(self): city_paths_to_apply = [] for p_num in range(PLAYERS): if self.player_city_inputs[p_num]: city_paths_to_apply.extend(self.player_city_inputs[p_num]) self.player_city_inputs = [[] for i in range(PLAYERS)] self.environment.update_cities(city_paths_to_apply) paths_to_apply = [] for p_num in range(PLAYERS): if self.player_inputs[p_num]: paths_to_apply.extend(self.player_inputs[p_num]) self.player_inputs = [[] for i in range(PLAYERS)] self.ready = False self.environment.update_troops(paths_to_apply) self.ready = True try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() wod_client.py: import simple_socket import pygame import json from constants import * def interp(threshold, a, b): if a == b: return 0.5 t = (threshold - a) / (b - a) return max(0.0, min(1.0, t)) def marching_squares(grid, cell_size, rows, cols, threshold): segments = [] cs = cell_size for j in range(rows): for i in range(cols): c0 = grid[j][i] c3 = grid[j][i + 1] c2 = grid[j + 1][i + 1] c1 = grid[j + 1][i] p_top = interp(threshold, c0, c1) p_right = interp(threshold, c1, c2) p_bottom = interp(threshold, c3, c2) p_left = interp(threshold, c0, c3) x = j * cs y = i * cs p0 = (x + p_top * cs, y) p1 = (x + cs, y + p_right * cs) p2 = (x + p_bottom * cs, y + cs) p3 = (x, y + p_left * cs) idx = 0 if c0 > threshold: idx |= 1 if c1 > threshold: idx |= 2 if c2 > threshold: idx |= 4 if c3 > threshold: idx |= 8 if idx == 0 or idx == 15: pass elif idx == 1: segments.append((p3, p0)) elif idx == 2: segments.append((p0, p1)) elif idx == 3: segments.append((p3, p1)) elif idx == 4: segments.append((p1, p2)) elif idx == 5: segments.append((p3, p0)) segments.append((p1, p2)) elif idx == 6: segments.append((p0, p2)) elif idx == 7: segments.append((p3, p2)) elif idx == 8: segments.append((p2, p3)) elif idx == 9: segments.append((p0, p2)) elif idx == 10: segments.append((p0, p1)) segments.append((p2, p3)) elif idx == 11: segments.append((p1, p2)) elif idx == 12: segments.append((p1, p3)) elif idx == 13: segments.append((p0, p1)) elif idx == 14: segments.append((p3, p0)) return segments def marching_squares_poly(grid, cell_size, rows, cols, threshold): polys = [] cs = cell_size thr = threshold for i in range(rows): for j in range(cols): c0 = grid[i][j] c1 = grid[i][j + 1] c2 = grid[i + 1][j + 1] c3 = grid[i + 1][j] row_pos = i * cs col_pos = j * cs v0 = (row_pos, col_pos) v1 = (row_pos, col_pos + cs) v2 = (row_pos + cs, col_pos + cs) v3 = (row_pos + cs, col_pos) p_top = (row_pos, col_pos + interp(threshold, c0, c1) * cs) p_right = (row_pos + interp(threshold, c1, c2) * cs, col_pos + cs) p_bottom = (row_pos + cs, col_pos + interp(threshold, c3, c2) * cs) p_left = (row_pos + interp(threshold, c0, c3) * cs, col_pos) inside = [c0 > thr, c1 > thr, c2 > thr, c3 > thr] idx = 0 if inside[0]: idx |= 1 if inside[1]: idx |= 2 if inside[2]: idx |= 4 if inside[3]: idx |= 8 if idx == 0: continue if idx == 15: polys.append([v0, v1, v2, v3]) continue pts = { "v0": v0, "v1": v1, "v2": v2, "v3": v3, "p_top": p_top, "p_right": p_right, "p_bottom": p_bottom, "p_left": p_left, } specs = TABLE.get(idx, []) for spec in specs: poly = [pts[name] for name in spec] compact = [] for p in poly: if not compact or (abs(p[0] - compact[-1][0]) > 1e-9 or abs(p[1] - compact[-1][1]) > 1e-9): compact.append(p) if len(compact) >= 3: polys.append(compact) return polys def marching_squares_layers(grid, cell_size, rows, cols, thresholds): layers = [] for thr in thresholds: threshold = thr polys = marching_squares_poly(grid, cell_size, rows, cols, threshold) layers.append(polys) return layers class Game: def __init__(self, title): pygame.init() info_object = pygame.display.Info() desktop_width = info_object.current_w desktop_height = info_object.current_h self.size = (desktop_width - 20, desktop_height - 100) self.factor = min(self.size[0] / WORLD_X, self.size[1] / WORLD_Y) self.screen = pygame.display.set_mode(self.size) pygame.display.set_caption(title) pygame.event.set_allowed( [ pygame.KEYDOWN, pygame.QUIT, pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION, pygame.MOUSEWHEEL, ] ) self.clock = pygame.time.Clock() self.done = False self.zoom_levels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3.5, 4, 6] self.zoom_idx = self.zoom_levels.index(1) self.zoom = self.get_zoom(self.zoom_idx) self.camx, self.camy = 0.0, 0.0 self.panning = False self.pan_start_mouse = (0, 0) self.pan_start_cam = (0.0, 0.0) self.draw_info = None self.player_input = [[], []] self.paths = [] self.drawing_path = False self.city_paths = [] self.drawing_city_path = False self.pause = False self.terrain_by_zoom = {} def run_game(self): ip, port = input("ip\n: "), input("\nport\n: ") print("connecting...") self.client = simple_socket.Client(ip, PORTS[min(99, max(0, int(port)))]) self.client.connect() print("connection successful!") print("drawing terrain...") terrain_grid, forrest_grid, cities, self.player_num = json.loads(self.client.rcv()) self.color = COLORS[self.player_num] layers = marching_squares_layers(terrain_grid, CELL_SIZE, ROWS, COLS, list(TERRAIN_VALUES.values())) layers.append(marching_squares_poly(forrest_grid, CELL_SIZE, ROWS, COLS, THRESHOLD)) for i in range(len(self.zoom_levels)): z = self.get_zoom(i) sw = max(1, int(WORLD_X * z)) sh = max(1, int(WORLD_Y * z)) surf = pygame.Surface((sw, sh), pygame.SRCALPHA) for poly in layers[0]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (0, 220, 255), scaled, 0) for poly in layers[1]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (20, 180, 20), scaled, 0) for poly in layers[2]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (150, 150, 150), scaled, 0) for poly in layers[3]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (100, 100, 100), scaled, 0) for poly in layers[4]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (30, 125, 30), scaled, 0) for position in cities: if position is None: continue cx, cy = int(position[0] * z), int(position[1] * z) pygame.draw.circle(surf, (255, 215, 0), (cx, cy), max(1, int(15 * z))) self.terrain_by_zoom[z] = surf print("terrain drawn! starting game (waiting for other players)...") self.draw_info = json.loads(self.client.rcv()) while not self.done: self.handle_events() self.draw() pygame.display.flip() self.clock.tick(30) self.client.close() pygame.quit() def handle_events(self): if not self.pause: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.MOUSEBUTTONDOWN: if e.button == 3 and not self.drawing_path and not self.drawing_city_path: self.panning = True self.pan_start_mouse = e.pos self.pan_start_cam = (self.camx, self.camy) elif e.button == 4 and not self.drawing_path and not self.drawing_city_path: self.zoom_in_at(e.pos) elif e.button == 5 and not self.drawing_path and not self.drawing_city_path: self.zoom_out_at(e.pos) elif e.button == 1: mx, my = e.pos[0], e.pos[1] troops = self.draw_info[2] r = max(1, int(7 * self.zoom)) r3 = r * 3 best = None best_dist2 = None best_pos = None for pos, color, tid, owner, path, health in troops: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best is None or d2 < best_dist2: best_pos = pos best = tid best_dist2 = d2 if best is not None: self.drawing_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.paths): if id_path[0] == best: to_pop = i if to_pop is not None: self.paths.pop(to_pop) self.paths.append((best, [best_pos])) else: mx, my = e.pos[0], e.pos[1] cities = self.draw_info[3] r = max(1, int(7 * self.zoom)) r3 = r * 3 best_city = None best_dist2 = None best_pos = None for color, pos, cid, path, owner in cities: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best_city is None or d2 < best_dist2: best_pos = pos best_city = cid best_dist2 = d2 if best_city is not None: self.drawing_city_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.city_paths): if id_path[0] == best_city: to_pop = i if to_pop is not None: self.city_paths.pop(to_pop) self.city_paths.append((best_city, [best_pos])) elif e.type == pygame.MOUSEBUTTONUP: if e.button == 3: self.panning = False if e.button == 1: self.drawing_path = False self.drawing_city_path = False elif e.type == pygame.MOUSEMOTION: if self.drawing_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.paths[-1][1].append((wx, wy)) elif self.drawing_city_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.city_paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.city_paths[-1][1].append((wx, wy)) elif self.panning: mx, my = e.pos sx, sy = self.pan_start_mouse dx = (mx) - sx dy = (my) - sy self.camx = self.pan_start_cam[0] - dx / self.zoom self.camy = self.pan_start_cam[1] - dy / self.zoom self.clamp_camera() elif e.type == pygame.MOUSEWHEEL: if not self.drawing_path and not self.drawing_city_path: mx, my = pygame.mouse.get_pos() if e.y > 0: self.zoom_in_at((mx, my)) elif e.y < 0: self.zoom_out_at((mx, my)) elif e.type == pygame.KEYDOWN: if e.key == pygame.K_c: self.paths = [] self.city_paths = [] elif e.key == pygame.K_SPACE: if (not self.drawing_path and not self.drawing_city_path) and (self.paths or self.city_paths): for id, path in self.paths: path.pop(0) for id, path in self.city_paths: path.pop(0) self.player_input[0] = self.paths self.player_input[1] = self.city_paths self.paths = [] self.city_paths = [] elif e.key == pygame.K_p: self.player_input = "pause" self.pause = True else: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.KEYDOWN: if e.key == pygame.K_p: self.player_input = "unpause" self.pause = False self.client.send(json.dumps(self.player_input, separators=(",", ":"))) self.player_input = [[], []] def zoom_in_at(self, screen_pos): if self.zoom_idx < len(self.zoom_levels) - 1: self.set_zoom_index(self.zoom_idx + 1, screen_pos) def zoom_out_at(self, screen_pos): if self.zoom_idx > 0: self.set_zoom_index(self.zoom_idx - 1, screen_pos) def get_zoom(self, zoom_idx): return self.zoom_levels[zoom_idx] * self.factor def set_zoom_index(self, new_idx, screen_pos): old_zoom = self.zoom new_zoom = self.get_zoom(new_idx) sx, sy = screen_pos world_x = self.camx + sx / old_zoom world_y = self.camy + sy / old_zoom self.zoom_idx = new_idx self.zoom = new_zoom self.camx = world_x - sx / new_zoom self.camy = world_y - sy / new_zoom self.clamp_camera() def clamp_camera(self): max_camx = max(0.0, WORLD_X - (self.size[0] / self.zoom)) max_camy = max(0.0, WORLD_Y - (self.size[1] / self.zoom)) if self.camx < 0.0: self.camx = 0.0 if self.camy < 0.0: self.camy = 0.0 if self.camx > max_camx: self.camx = max_camx if self.camy > max_camy: self.camy = max_camy def draw(self): self.screen.fill((255, 255, 255)) vision_grid, border_grid, troops, cities = self.draw_info = json.loads( self.client.rcv() ) z = self.zoom terrain_surf = self.terrain_by_zoom[z] offset_x = int(-self.camx * z) offset_y = int(-self.camy * z) self.screen.blit(terrain_surf, (offset_x, offset_y)) dyn_w = max(1, int(WORLD_X * z)) dyn_h = max(1, int(WORLD_Y * z)) dynamic = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) fog = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) paths_to_draw = [] for color, position, cid, path, owner in cities: if path and owner == self.player_num: path.insert(0, position) paths_to_draw.append(path) if color is not None: px = int(position[0] * z) py = int(position[1] * z) pole_bottom = (px, py) pole_top = (px, int(py - 30 * z)) pygame.draw.line(dynamic, (80, 80, 80), pole_bottom, pole_top, max(1, int(3 * z))) flag_color = tuple(color) if isinstance(color, (list, tuple)) else color fw, fh = int(20 * z), int(14 * z) p1 = (pole_top[0], pole_top[1]) p2 = (pole_top[0] + fw, pole_top[1] + fh // 2) p3 = (pole_top[0], pole_top[1] + fh) pygame.draw.polygon(dynamic, flag_color, [p1, p2, p3]) pygame.draw.polygon(dynamic, (0, 0, 0), [p1, p2, p3], max(1, int(1 * z))) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (240, 180, 0), (px, py), (px2, py2), max(1, int(4 * z))) paths_to_draw = [] tids = [tid for tid, path in self.paths] for pos, color, tid, owner, path, health in troops: px = int(pos[0] * z) py = int(pos[1] * z) r = max(1, int(7 * z)) rgb = color if tid in tids: factor = 0.5 rgb = [max(0, min(255, int(x * factor))) for x in color] if path and owner == self.player_num: path.insert(0, pos) paths_to_draw.append(path) pygame.draw.rect(dynamic, (0, 255, 0), pygame.rect.Rect(px-r, (py-r)-max(1, int(3 * z)), (r*2)*(health/100), max(1, int(3 * z)))) pygame.draw.circle(dynamic, rgb, (px, py), r) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, self.color, (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.city_paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for a, b in marching_squares(border_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): ax = int(a[0] * z) ay = int(a[1] * z) bx = int(b[0] * z) by = int(b[1] * z) pygame.draw.line(fog, (0, 0, 0), (ax, ay), (bx, by), max(1, int(3 * z))) for poly in marching_squares_poly(vision_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(fog, (0, 0, 0, 150), scaled, 0) if self.pause: font = pygame.font.SysFont(None, 48) text_surface = font.render("Pause", False, (0, 0, 0)) fog.blit(text_surface, (10, 10)) self.screen.blit(dynamic, (offset_x, offset_y)) self.screen.blit(fog, (offset_x, offset_y)) game_play = Game("WAR OF DOTS") game_play.run_game() simple_socket.py: import socket ####### https://docs.python.org/3/library/socket.html#socket.socket.sendfile ####### #socket.gethostbyname(str(socket.gethostname()))# HEADER, FORMAT = 64, "utf-8" class Client: def __init__(self, servip, port): self.servip = servip self.port = port def connect(self): self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) ADDR = (self.servip, self.port) self.client.connect(ADDR) def send(self, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) self.client.sendall(send_length) self.client.sendall(message) def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self): self.client.close() class Server: def __init__(self, ip, port): self.ip = ip self.port = port def start(self): self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) ADDR = (self.ip, self.port) self.server.bind(ADDR) self.conns = [] def lsn(self, conns=0): if conns > 0: self.server.listen(conns) else: self.server.listen() def accept(self): conn, addr = self.server.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.conns.append(conn) return conn, addr def send(self, conns, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) for conn in conns: conn.sendall(send_length) conn.sendall(message) def rcv(self, conn): msg_length = conn.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = conn.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self, conn): conn.close() self.conns.remove(conn) constants.py: CELL_SIZE = 20 SIZE = (1280, 700) WORLD_X, WORLD_Y = SIZE ROWS = int(SIZE[0]//CELL_SIZE) COLS = int(SIZE[1]//CELL_SIZE) TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } TROOP_R = 7 THRESHOLD = 0.5 PLAYERS = 2 COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] TABLE = { 1: [["v0", "p_top", "p_left"]], 2: [["v1", "p_right", "p_top"]], 3: [["p_left", "v0", "v1", "p_right"]], 4: [["v2", "p_bottom", "p_right"]], 5: [["v0", "p_top", "p_left"], ["v2", "p_right", "p_bottom"]], 6: [["p_top", "v1", "v2", "p_bottom"]], 7: [["p_left", "v0", "v1", "v2", "p_bottom"]], 8: [["v3", "p_left", "p_bottom"]], 9: [["p_top", "v0", "v3", "p_bottom"]], 10: [["p_top", "v1", "p_right"], ["p_bottom", "v3", "p_left"]], 11: [["v0", "v1", "p_right", "p_bottom", "v3"]], 12: [["p_right", "v2", "v3", "p_left"]], 13: [["v0", "p_top", "p_right", "v2", "v3"]], 14: [["v1", "v2", "v3", "p_left", "p_top"]], } PORTS = [i for i in range(1200, 1300)] Question: Ultimately I am looking for performance because I want to increase map size for more players and not lag the server or clients. Any other improvements and bug fixes (I hope there are none!) are welcome. Gameplay suggestions are especially welcome if you actually play it! EDIT: Also I have found a few bugs; the zoom controls are bugged and the bottom left and top right city selections were wrong so ignore them for now pls. oh! and the brush apply function doesn't go to the bottom and right edges. Bounties: I will be placing bounties, to reward the efforts people go to to review this code beyond the accepted answer. pythonperformancepygamebattle-simulation Share Follow edited Feb 18 at 0:41 asked Feb 3 at 10:40 coder's user avatar coder 31911 silver badge2424 bronze badges 2 You really made me watch trailer/videos about war of dots and you image seems quite like the original. I'd hope to find time for this question, it really looks fun. – Thingamabobs CommentedFeb 4 at 4:16 1 Not exactly the feedback you were looking for, but this very much looks like a golf simulator. We see the holes (only 10 of them, so I guess a short course), the green, the rough, the water traps, the locations of the balls people have been hitting, and I guess a dirt trap? I can't unsee it. Best of luck on the game. – Seth Robertson CommentedFeb 5 at 0:18 @SethRobertson haha thanks, i love how it turned out visually and hope it clearly displays the game state. – coder CommentedFeb 5 at 1:07 Add a comment 4 Answers Sorted by: Highest score (default) 3 +50 Wow, what a great game! Thank you. I still need to diagnose why pressing "P" to pause causes fatal stacktrace. initial rendezvous Using input() is kind of OK. But you should definitely let me specify "127.0.0.1" and port "0" in sys.argv, with perhaps the host defaulting to localhost or to the value of an optional env var. The whole business of discussing "port 0" with the user, and then biasing it by 1200 during the bind() call, is needlessly confusing. Just tell the user that ports like 1200 and 1201 will be used. Better yet, make it automatic. The user specifies a hostname, on which server is already running, and the client makes a connection and prints out how many users are already on that server. If zero existing players, then make me 0, and so on. I was a little surprised that all players can connect using same port number, or using distinct port numbers, and in both situations the game works fine. It feels like we're exposing more complexity than strictly necessary. diagnostic Personally, I found getting a ConnectionRefusedError very informative. But you might want a try / except which replaces that with some advice about needing to run the server before running any clients. import PEP-8 asks for three sections of imports, each alphabetically sorted. import json import perlin_noise import math import simple_socket Use isort to accomplish that. Why does it matter? I wound up doing uv add simple_socket and pulling in simple-socket 0.0.10 from pypi, before I eventually noticed your simple_socket.py module. Grouping those libraries tells the Reader where the library came from, and where to go looking for the docs. In your repo you should definitely add a Makefile or similar bash script that shows how you produce a .exe when cutting a release. Consider publishing binaries on a different site, or at least in a different repo, as git really wants to diff text edits in order to avoid bloat. Binaries are incompressible, so a lot of git history can accumulate in short order. You should publish a pyproject.toml file that shows how collaborators should install dependencies, and how you publish to pypi. wildcard from constants import * Uggh! Please don't. Remove the star, note the missing symbols, and use your IDE's auto-complete to fill them in explicitly. As a Reader, when I see star I think "all bets are off", because if I don't recognize an unfamiliar symbol I won't know if it should have matched the wildcard. Even worse when more than one import is wildcarded. Also, I can't grep to see where something came from. tuple unpack In xy_to_dir_dis() we see atan2(xy[1], xy[0]). Please refrain from using those cryptic [1], [0] subscripts which are unrelated to the problem at hand. Prefer to speak plainly: x, y = xy Then you can use a straightforward atan2(y, x) call. Also, 0 - x is weird, where unary -x would suffice. And why are we negating in the first place, given that the squaring will make it positive? Consider introducing Polar and Cartesian coordinate classes, or borrowing them from an existing pypi library or from the builtin Complex type. Consider keeping the internal representation as radians. pointers self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] Wow, that's a lot of 64-bit pointers to (double prec.) float objects. Prefer array, for a more compact representation with less pointer indirection. If nothing else, smaller footprint is more cache friendly. Better still, use an NDArray, if you're willing to take a dep on NumPy. Consider whether 32-bit single precision floats would be adequate, here. meaningful identifier def get_grid_value(self, x, y): ... val = ( ... This is indeed a getter. But value & val are on the vague side; I would love to know the meaning of the returned number. And I'm not too sure we're properly using the word grid here. It feels more like there's a continuous battle field, occasionally punctuated by grid points, and this interpolation helper is performing get_field_potential_from_grid(). (I imagine that each Red and Blue unit on the field imposes a plus or minus potential, and we're reporting the net potential.) A """docstring""" would have gone a long way toward illuminating Author's Intent for what this helper does. edge case Sorry, but when reading x2, ... = min(x1 + 1, ROWS), ..., I'm not quite sure what to make of it. We already have that x1 is an integer. So either x2 is one bigger, or when at the ROWS limit x2 is identical to x1, so "interpolate" works out to "just return the coarse grid value". And we might be at the limit in one direction but not the other. Seems like it's worth tacking on a sentence at the end of the docstring, or at least add a # comment. The docstring should additionally cite https://en.wikipedia.org/wiki/Marching_squares invariants In Brush.apply, I'm a little surprised that we need to verify radius is positive -- not sure what would shrink it. And we could have just initialized at 40.0, without need for a float() call. enum Helpers like get_terrain_name() make it look like we really want to store names and speed values in an Enum. We could then put such mapping helpers within that class. @dataclass get_terrain_info() looks like it wants to return a TerrainInfo dataclass. helpers As you commented, update_troops() clearly needs to break out several helper functions. The closest_city attribute looks like it could be memoized, since cities don't move. (Not sure why they have a path.) For each player and each discretized grid point, compute and store the closest city. Then a troop can use int() to get its grid point, and look up the closest city without looping. There's a lot of magic numbers in there. Am I worried that they're not given names? No, not really. It does worry me that they don't show up in the player documentation, and as a player it's hard for me to visualize those interactions on-screen. I can't tell if a unit I just moved is now in or out of range for one or another of those special cases. In the Game constructor, 1200 certainly deserves a name with PORT in it. mutable defaults def __init__(self, ..., path=None): ... self.path = path if path is not None else [] Thank you for avoiding a mutable Troop default. The usual idiom here is slightly more compact: self.path = path or [] In the general case we'd need what you wrote, if e.g. path could take on a "falsey" value like 0.0. But here it's only the len() of a list that matters. It seems odd you're caching id(self), since you could cheaply ask for it any time you need it. number of players I propose that there's six players, all the time. But initially they're all inactive. And as TCP clients join, they claim the first inactive player. This makes joining a game after some delay more flexible, and reduces the interactive questions we have to ask up front. a boolean variable is already a boolean ... or self.done == True Prefer a simple ... or self.done. Also, looking at {ready, started, done}, perhaps we have more state variables than needed? For example, the "sleep 100 msec till self.started" loop could perhaps be removed if we deferred creating Players and threads until all had joined. Also, I'm not quite understanding self.done. When Blue defeats all Red cities and units, the game continues to play, rather than declaring victory. main guard wod_server.py contains quite a lot of code, and that is fine. But we don't have the traditional if __name__ == "__main__": guard, so automated unit tests cannot safely import this module without undesired side effects, and that is a problem. contract The wod_client interp() function really needs a docstring. What promises is it making to the caller? And marching_squares() needs either a docstring or at least some comments, perhaps a literature citation. Also, in handle_events() and in draw() we need to break out some helpers. "values" TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } Yes, each of those four is a numeric "value". But tell us what those numbers mean. Do they relate to vision? To mobility? COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] Consider defining some Enums or some MANIFEST_CONSTANTS, and then put named colors into that list. gameplay grid markings Distance between units plays an important role in this game. Consider superimposing a purely cosmetic square or hex grid upon the display, to help folks visually judge relative distances. Paper maps often have a "12 miles to the inch" type of legend in the corner, with some black & white markings that let you put your thumb first here, then there, and say it's so many miles to destination. If there's no visible grid, consider putting some standard size markings in a corner legend. within range I have some idea of which units are interacting (killing) which other units. But it's not completely obvious, and I was surprised at how many special cases the server code handles. I feel like there's enough information available to the client module that it could choose to render orange lighting zaps coming from an attacking unit, that sort of thing. To support this, we would need a common library module which both server and client import, describing the damage model. There's two things I care about for a unit: health (damage), and rate of damage (or rate of healing) With the current game display, I have a hard time knowing which units in the fray are actually interacting, and if I have pulled a damaged unit "far enough" away from the melee. There is room to reveal derivative of health, IDK maybe with a simple left/right arrow to show it is dwindling or increasing. random damage After Blue defeats all Red cities and troops, I still observe Blue deaths, and respawns at cities. I cannot explain the damage / death events. update speed We already track FPS. I wouldn't mind seeing a one-line speed report that appears once per minute. It could mention how much usermode CPU we've been using recently. I would love to know about episodes where we asked for an unusually small sleep() time. There's no automated unittest for a server update cycle, and that makes it hard to profile the server code to see where the hot spots are. A test could include a scaling parameter, so we compare elapsed time on small vs. large maps or army sizes. It is often the case that "most" troops lack an active path. During profiling I would want to know if a "keep the status quo" approach is a good fit for such idle troops, so they're updated less often. The marching squares (visibility) calculations could perhaps be scheduled less often than troop updates are, without being visually jarring. Every nth frame might suffice. summary score Maybe display total number of per-player troops? (Or does that only become of interest once a player loses all cities?) Total number of "deployed" (out-of-city) troops? Total deaths? Maybe display the current aggregate damage (or "heal") rate for each player? Maybe a mouse-over on a troop would reveal its damage rate? Could be a tooltip near the pointer, but probably better to bury it in a corner display near the legend, so it's less distracting. Share Follow edited Feb 7 at 16:05 answered Feb 6 at 21:36 J_H's user avatar J_H 46k33 gold badges4141 silver badges167167 bronze badges is the overhead of converting ndarray to list and back for json worth using ndarrays? (great answer btw +1 and likely +50) – coder CommentedFeb 7 at 0:37 1 "overhead of converting ndarray to list and back for json" Surely we don't do that 45 times per second, right? In any event, nobody cares what one person's opinion might be. It sounds like we want to Extract Helper so the pygame loop can call into the same update() that a unit test or timeit() calls into. And then "worth it" is just a matter of comparing one elapsed time against another. // I happened to be playing Blue. I'm happy to play Red until I witness Blue's extinction, and I imagine I will see a similar "Red troops die / respawn even with no enemy troops alive" effect. – J_H CommentedFeb 7 at 1:35 do what you like with testing its your time/answer || ill look into sending numpy info, it probably will end up worth doing for performance because the brush apply could be improved with numpy for sure, the main performance issue it seems rn 1269497 72.511 0.000 115.930 0.000 wod_server.py:58(apply) – coder CommentedFeb 7 at 1:46 1 Ok, issue 2 shows the relevant screenshot for "unexpected damage". Automated unit tests would help with narrowing this down. – J_H CommentedFeb 7 at 4:26 Add a comment 7 +100 UX When I run the wod_server.py code, I get this prompt: Enter number of players (2-6): That is easy to understand. Then I get this prompt: Enter port to use (0 - 99): But, I am not sure what the numbers mean. The prompt could be a little more specific. It would be better to print a few lines of information before the prompts, giving the user more context. Briefly describe the game to someone who has never played it. You could also offer an option to bypass the introduction when you run the code (for users who have already played it). The code also appears hung for me at this line of output: waiting for players... I don't know how to proceed to actually play the game. Perhaps the code is waiting for someone to run the wod_client.py code. If that is the case, it would be good to explicitly state it. When I run wod_client.py, I get a GUI window, but it is just a black screen, and I don't know what to do. Documentation The PEP 8 style guide recommends adding docstrings for classes and functions. Consider using type hints to describe input and return types of the functions to make the code even more self-documenting. Comments Comments are intended to describe the code, not question it: def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? That comment should be deleted. Simpler I believe this line: (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 can be simplified as: xy[0] ** 2 + xy[1] ** 2 Give it a try. Also, instead of math.sqrt, you can simplify further using hypot: def xy_to_dir_dis(xy): return math.degrees(math.atan2(xy[1], xy[0])), math.hypot(xy[0], xy[1]) hypot can also be used in the elevation_bias function. When I see lines like: self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] I wonder if the code could be simplified by using numpy, which may also have performance benefits. Main guard It is customary to add a "main" guard at the end of the code in file wod_server.py: if __name__ == '__main__': try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() Naming PEP 8 only recommends all caps for constants, not variables. In wod_server.py, PLAYERS would be players. Tools You could run code development tools to automatically find some style issues with your code. ruff identifies things like this in wod_server.py: E714 [*] Test for object identity should be `is not` | | self.city_border_brush.apply(player.border, city.position, 1.0) | for other_player in self.players: | if not player is other_player: | ^^^^^^^^^^^^^^^^^^^^^^ E714 | for city in self.cities: | if city.owner is other_player: | = help: Convert to `is not` Share Follow answered Feb 3 at 12:50 toolic's user avatar toolic 21.9k66 gold badges3232 silver badges270270 bronze badges Add a comment 5 Context managers Both your Server and Client classes in simple_socket.py have close functions. I can't help but think that maybe these classes should implement the behaviors of a context manager so you don't necessarily have to explicitly manage this. Boolean comparisons Don't do this. self.done == True Simpler: self.done Early exits In simple_socket.py you have a function: def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" I can't help but think this reads better with an early return in the event msg_length is false. def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if not msg_length: return "" msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) You also never use msg_length except after converting it to an int so rather than repeating yourself: def rcv(self): msg_length = int(self.client.recv(HEADER).decode(FORMAT)) total_received = 0 if not msg_length: return "" msg = [] while total_received < msg_length: data = self.client.recv(msg_length) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) DRY In the following I see a lot of repetition in setting self.players. You may wish to create a list of players once and then assign slices of that list to self.players depending on the value of PLAYERS. if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] Share Follow answered Feb 3 at 15:54 Chris's user avatar Chris 18.2k11 gold badge1414 silver badges9292 bronze badges Add a comment 5 I have not attempted to run this program, so these are just some random thoughts after performing a cursory review. Enum First of all, you deal with tuples a lot, so using namedtuple would make sense at some places. Enums are underutilized as well. For example, instead of: COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] I would suggest this approach: from enum import Enum class Color(Enum): RED = 255, 0, 0 GREEN = 0, 255, 0 BLUE = 0, 0, 255 WHITE = 255, 255, 255 BLACK = 0, 0, 0 print(Color.BLUE) Then you can use expressive color names, that make the code immediately more descriptive. In fact, the parentheses are not needed here, you can remove them. I have not bothered to introduce a full RGB class, this setup is sufficient for your purpose. You could also use namedtuple for coordinates. As shown in another question from collections import namedtuple Point = namedtuple('Point', 'x y', defaults=[0, 0]) I regret the lack of comments. Since the code is fairly long, and without being privy to the game, the flow is not easy to follow without testing the program and studying the rules of the game. From time to time, it would be helpful to comment blocks of code, to explain what you are doing at this specific point and why. Naming Function names are not always intuitive. For example, update_cities does not tell me what exactly we are doing here. Other names like xy_to_dir_dis are somewhat cryptic. Don't be afraid to use longer names, there is no bonus for abbreviating things. To take a random example: def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) You are assigning a new variable cs, which takes the value of CELL_SIZE. Just for the sake of abbreviating. That only obfuscates the code. Likewise, self.radius is much more expressive than just r, which could mean anything. And you could explicitly cast self.radius to float in your __init__ method if that matters. But many variable names are very short, which does not facilitate comprehension, and can even be a cause of bugs in my opinion. Class Some classes like Troop, City, Player should be made @dataclass to reduce boilerplate a little bit. In class Environment, the assignment of self.players is quite dense and there is repeat code. I think it would be interesting to explore dataclass and especially the default_factory method. Suggested article which I found helpful: Python Data Classes: A Comprehensive Tutorial Share Follow answered Feb 3 at 17:37 Kate's user avatar Kate 11k1010 silver badges3030 bronze badges Add a comment You must log in to answer this question. Start asking to get answers Find the answer to your question by asking. Explore related questions pythonperformancepygamebattle-simulation See similar questions with these tags. The Overflow Blog Your LLM issues are really data issues Welcome to the “find out” stage of AI Report this ad Report this ad Linked 5 YouTube Downloader implementation using CustomTkinter and Pytubefix 9 Maze Solver in Python inspired by Micro-Mouse Competition Logic 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Related 2 War card game simulator 12 Script for a Civil War game 9 Simple top down shooter game 3 Python 4 Players Snake Game 6 Python/Pygame Fighting Game 5 Simple Python Pygame Game 2 Card game of war using pygame module 5 Very simple Flappy 'Bird' game - First project in Python 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Hot Network Questions Which type of barrel adjuster is missing here? First book of series might leave a bad impression; rewrite or not? What precedent did the 2 Live Crew case actually set? How can we obtain a smoother sphere when cutting it with a plane and moving the cut portion? Non-quasi-separated perfectoid spaces more hot questions Question feed Code Review Tour Help Chat Contact Feedback Company Stack Overflow Stack Internal Stack Data Licensing Stack Ads About Press Legal Privacy Policy Terms of Service Your Privacy Choices Cookie Policy Stack Exchange Network Technology Culture & recreation Life & arts Science Professional Business API Data Blog Facebook Twitter LinkedIn Instagram Site design / logo © 2026 Stack Exchange Inc; user contributions licensed under CC BY-SA . rev 2026.4.23.42490 import json import perlin_noise import math import simple_socket import socket import time import threading import random from constants import * def dir_dis_to_xy(direction, distance): return ( (distance * math.cos(math.radians(direction))), (distance * math.sin(math.radians(direction))), ) def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? return math.degrees(math.atan2(xy[1], xy[0])), math.sqrt( (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 ) class MarchingSquares: def __init__(self): self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] def set_grid(self, new_grid): self.grid = new_grid def get_grid_value(self, x, y): x1, y1 = int(x), int(y) x2, y2 = min(x1 + 1, ROWS), min(y1 + 1, COLS) dx, dy = x - x1, y - y1 p11 = self.grid[x1][y1] p21 = self.grid[x2][y1] p12 = self.grid[x1][y2] p22 = self.grid[x2][y2] val = ( p11 * (1 - dx) * (1 - dy) + p21 * dx * (1 - dy) + p12 * (1 - dx) * dy + p22 * dx * dy ) return val class Brush: def __init__(self, radius=40, strength=1.0, falloff=1.0): self.radius = radius self.strength = strength self.falloff = falloff def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) inv_r = 1.0 / r grid = marching_squares.grid strength = self.strength falloff = self.falloff for j in range(row_start, row_end): px = j * cs dx_sq = (px - mx) ** 2 row = grid[j] for i in range(col_start, col_end): py = i * cs dy = py - my dist_sq = dy * dy + dx_sq if dist_sq <= r * r: dist = math.sqrt(dist_sq) t = dist * inv_r weight = strength + t * (falloff - strength) old = row[i] row[i] = max(0.0, min(1.0, old + (target_value - old) * weight)) class Environment: def __init__(self): self.terrain_speeds = { "water": 0.6, "forest": 0.8, "plains": 1, "hill": 0.7, "mountain": 3, } self.terrain_attacks = { "water": 0.5, "forest": 0.75, "plains": 1, "hill": 1.5, "mountain": 0, } self.terrain_marching = MarchingSquares() self.forest_marching = MarchingSquares() self.cities = [] self.default_vision = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] for y in range(COLS + 1): for x in range(ROWS + 1): self.default_vision[x][y] = 0.0 self.generate_terrain() self.generate_default_vision() # 2 players left and right most cities # 3 players left-bottom, top, right-bottom # 4 players left-bottom, top-left, top-right, right-bottom # 5 players left-bottom, top-left, middle, top-right, right-bottom # 6 players left-bottom, top-left, middle-left, middle-right, top-right, right-bottom left_bottom_city = min(self.cities, key=lambda c: c.position[0] + c.position[1]) top_left_city = min(self.cities, key=lambda c: c.position[0] - c.position[1]) middle_top_city = min( self.cities, key=lambda c: (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5) + c.position[1], ) middle_bottom_city = max( self.cities, key=lambda c: c.position[1] - (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5), ) top_right_city = max(self.cities, key=lambda c: c.position[0] - c.position[1]) right_bottom_city = max( self.cities, key=lambda c: c.position[0] + c.position[1] ) left_city = min(self.cities, key=lambda c: c.position[0]) right_city = max(self.cities, key=lambda c: c.position[0]) top_city = max(self.cities, key=lambda c: c.position[1]) middle_city = min( self.cities, key=lambda c: abs(c.position[0] - (ROWS * CELL_SIZE) / 2) + abs(c.position[1] - (COLS * CELL_SIZE) / 2), ) if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] self.vision_brush = Brush(75, 1, 0) self.city_vision_brush = Brush(175, 1, 0) self.border_brush = Brush(40, 0.05, 0) self.city_border_brush = Brush(80, 0.05, 0) self.players_in_cities = [[] for _ in self.cities] def generate_terrain(self): def elevation_bias(x, y): cx = ROWS / 2 cy = COLS / 2 dx = abs(x - cx) dy = abs(y - cy) dist = math.sqrt((dx) ** 2 + (dy) ** 2) max_dist = math.sqrt((cx) ** 2 + (cy) ** 2) return 1.0 - (dist / max_dist) noise = perlin_noise.PerlinNoise(octaves=3) for y in range(COLS + 1): for x in range(ROWS + 1): value = max( 0, min(1, ((noise([x / 25, y / 25])) - 0.2) + (elevation_bias(x, y))), ) self.terrain_marching.grid[x][y] = value forest_noise = perlin_noise.PerlinNoise(octaves=1.1) for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] value = (min(0.6, forest_noise([x / 30, y / 30])) * 2.0) + 0.3 plains_diff = max(0, (TERRAIN_VALUES["plains"] + 0.1) - terrain_value) hill_diff = max(0, terrain_value - (TERRAIN_VALUES["hill"] - 0.1)) self.forest_marching.grid[x][y] = ( value - (plains_diff * 10) ) - hill_diff * 10 def within_edges(cx, cy): edge_margin = int(1) return ( cx >= edge_margin and cx <= ROWS - edge_margin and cy >= edge_margin and cy <= COLS - edge_margin ) tries = 0 distance = 15 while True: cx = random.randint(0, ROWS) cy = random.randint(0, COLS) terrain_value = self.terrain_marching.grid[cx][cy] if ( ( terrain_value > TERRAIN_VALUES["plains"] and terrain_value < TERRAIN_VALUES["hill"] ) and all( abs(cx * CELL_SIZE - city.position[0]) + abs(cy * CELL_SIZE - city.position[1]) >= CELL_SIZE * distance for city in self.cities ) and within_edges(cx, cy) and self.forest_marching.grid[cx][cy] < THRESHOLD ): px = cx * CELL_SIZE py = cy * CELL_SIZE self.cities.append(City((px, py))) distance = 15 if len(self.cities) >= 10: break tries += 1 if tries >= 100: distance = max(2, distance - 2) tries = 0 def generate_default_vision(self): for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] forest_value = self.forest_marching.grid[x][y] self.default_vision[x][y] = 0.35 + ( max(min((((terrain_value + 0.1) / 1) + 0.2), 1), 0.2) + (0.8 if forest_value > 0.6 else 0.0) ) def draw_info(self, player): ply = self.players[player] vision_grid = ply.vision.grid border_grid = ply.border.grid troops = [] cities = [ ( c.owner.color if c.owner is not None else None, c.position, c.id, c.path, self.players.index(c.owner) if c.owner is not None else -1, ) for c in self.cities ] for troop in [t for p in self.players for t in p.troops]: ply = self.players[player] vision = ply.vision px, py = troop.position gx = px / CELL_SIZE gy = py / CELL_SIZE gx = max(0, min(ROWS, gx)) gy = max(0, min(COLS, gy)) if vision.get_grid_value(gx, gy) < THRESHOLD: troops.append( ( troop.position, troop.owner.color, troop.id, self.players.index(troop.owner), troop.path, troop.health, ) ) return vision_grid, border_grid, troops, cities def get_terrain_info(self): return ( self.terrain_marching.grid, self.forest_marching.grid, [c.position for c in self.cities], ) def get_terrain_name(self, value, fvalue): if fvalue > THRESHOLD: return "forest" for name, v in reversed(TERRAIN_VALUES.items()): if value > v: return name def update_troops(self, paths_to_apply): # split into more functions ? self.players_in_cities = [[] for _ in self.cities] troop_ids = [info[0] for info in paths_to_apply] troop_paths = [info[1] for info in paths_to_apply] for player in self.players: player.vision.grid = [row[:] for row in self.default_vision] for city in self.cities: if city.owner is player: self.city_vision_brush.apply(player.vision, city.position, 0) self.city_border_brush.apply(player.border, city.position, 1.0) for other_player in self.players: if not player is other_player: for city in self.cities: if city.owner is other_player: self.city_border_brush.apply( player.border, city.position, 0.0 ) to_remove = [] for troop in player.troops: if troop.health <= 0: to_remove.append(troop) continue try: tidx = troop_ids.index(id(troop)) troop.path = troop_paths[tidx] except ValueError: pass old_pos = troop.position owned = [city.position for city in self.cities if city.owner is player] if owned: closest_city = min( owned, key=lambda x: xy_to_dir_dis( ((old_pos[0] - x[0]), (old_pos[1] - x[1])) ), ) city_dir, city_dist = xy_to_dir_dis( ((old_pos[0] - closest_city[0]), (old_pos[1] - closest_city[1])) ) sample_points = [ dir_dis_to_xy(city_dir, dist * 20) for dist in range(int(city_dist // 20)) ] border_avg = 0 if sample_points: border_avgs = [] for other_player in self.players: if other_player is not player: border_avgs.append( sum( [ other_player.border.get_grid_value( (closest_city[0] + s_p[0]) / CELL_SIZE, (closest_city[1] + s_p[1]) / CELL_SIZE, ) for s_p in sample_points ] ) / len(sample_points) ) border_avg = sum(border_avgs) / len(border_avgs) dist_penal = max(((city_dist + 250) / 1000), 0.5) healing_power = (1 - (border_avg / 2)) - dist_penal else: healing_power = -0.5 troop.health += healing_power / 25 if troop.health > 100: troop.health = 100 enemies_in_range = [] gx = old_pos[0] / CELL_SIZE gy = old_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) on_terrain = self.get_terrain_name(terrain, forest) if troop.path: target = troop.path[0] terrain_speed = self.terrain_speeds[on_terrain] dir, distance = xy_to_dir_dis( (target[0] - old_pos[0], target[1] - old_pos[1]) ) distance = terrain_speed * 0.1 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) new_pos = (old_pos[0] + new_off_x, old_pos[1] + new_off_y) for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 14: distance = 14 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain dir, distance = xy_to_dir_dis( (target[0] - troop.position[0], target[1] - troop.position[1]) ) if distance < (terrain_speed * 2): troop.path.pop(0) else: new_pos = old_pos for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 15: distance += 0.025 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain if enemies_in_range: attack_power = self.terrain_attacks[on_terrain] / 25 closest = min(enemies_in_range, key=lambda x: x[1]) closest[0].health -= attack_power if on_terrain == "hill": self.city_vision_brush.apply(player.vision, troop.position, 0) else: self.vision_brush.apply(player.vision, troop.position, 0) self.border_brush.apply(player.border, troop.position, 1.0) for i, city in enumerate(self.cities): cx, cy = city.position tx, ty = troop.position dir, dist = xy_to_dir_dis((tx - cx, ty - cy)) if dist < 15: self.players_in_cities[i].append(player) break to_remove.reverse() for t in to_remove: player.troops.remove(t) def update_cities(self, paths_to_apply): city_ids = [info[0] for info in paths_to_apply] city_paths = [info[1] for info in paths_to_apply] for i, city in enumerate(self.cities): try: cidx = city_ids.index(id(city)) city.path = city_paths[cidx] except ValueError: pass cx, cy = city.position last_owner = city.owner if len(self.players_in_cities[i]) == 1: city.owner = self.players_in_cities[i][0] if last_owner is not city.owner: city.timer = 0 city.path = [] if city.owner is not None: city.timer += 1 t_per_c = len(city.owner.troops) / len( [c for c in self.cities if c.owner == city.owner] ) if city.timer >= 45 * (30 * t_per_c) and t_per_c < 10: city.owner.troops.append( Troop( ( cx + random.randrange(-6, 6), cy + random.randrange(-6, 6), ), city.owner, city.path.copy(), ) ) city.timer = 0 class Troop: def __init__(self, position, owner, path=None): self.position = position self.health = 100 self.path = path if path is not None else [] self.owner = owner self.id = id(self) class City: def __init__(self, position): self.position = position self.timer = 0 self.owner = None self.id = id(self) self.path = [] class Player: def __init__(self, start_pos, color, environment): self.start_pos = start_pos self.color = color self.troops = [Troop(self.start_pos, self)] self.border = MarchingSquares() self.vision = MarchingSquares() self.vision.grid = [row[:] for row in environment.default_vision] class Game: def __init__(self): self.FPS = 45 self.last_time = time.perf_counter() self.frame_time = 1 / self.FPS self.done = False self.server = simple_socket.Server( socket.gethostbyname(str(socket.gethostname())), 1200 ) self.environment = Environment() self.player_inputs = [[] for i in range(PLAYERS)] self.player_city_inputs = [[] for i in range(PLAYERS)] self.player_pause_requests = [False for i in range(PLAYERS)] self.started = False def run_game(self): self.ready = True try: port = int(input("Enter port to use (0 - 99): ")) self.server.port = PORTS[max(0, min(99, port))] except ValueError: pass print("ip: ", self.server.ip, ", port: ", self.server.port) print("starting server...") self.server.start() print("waiting for players...") self.server.lsn(conns=PLAYERS) for player_num in range(PLAYERS): conn, addr = self.server.accept() player_thread = threading.Thread( target=self.handle_player, args=(player_num, conn, addr) ) player_thread.start() print("player: ", player_num, " connected") print("All players connected, starting game!") self.started = True while not self.done: if not all(self.player_pause_requests): self.game_logic() current_time = time.perf_counter() delta_time = current_time - self.last_time self.last_time = current_time if delta_time < self.frame_time: time.sleep(self.frame_time - delta_time) # elif delta_time < self.frame_time*0.75: # self.dots = len(self.environment.players[0].troops) # print(self.dots) def handle_player(self, player_number, conn, addr): self.server.send( [conn], json.dumps( ( *self.environment.get_terrain_info(), player_number, ), separators=(",", ":"), ), ) while not self.started: time.sleep(0.1) draw_info = json.dumps([[], [], [], []], separators=(",", ":")) while True: if self.ready: draw_info = json.dumps( self.environment.draw_info(player_number), separators=(",", ":") ) self.server.send([conn], draw_info) else: self.server.send([conn], draw_info) player_in = json.loads(self.server.rcv(conn)) if player_in == "close" or self.done == True: self.done = True self.server.close(conn) print("player: ", player_number, " left") break if player_in: if player_in == "pause": self.player_pause_requests[player_number] = True elif player_in == "unpause": self.player_pause_requests[player_number] = False else: self.player_inputs[player_number].extend(player_in[0]) self.player_city_inputs[player_number].extend(player_in[1]) def game_logic(self): city_paths_to_apply = [] for p_num in range(PLAYERS): if self.player_city_inputs[p_num]: city_paths_to_apply.extend(self.player_city_inputs[p_num]) self.player_city_inputs = [[] for i in range(PLAYERS)] self.environment.update_cities(city_paths_to_apply) paths_to_apply = [] for p_num in range(PLAYERS): if self.player_inputs[p_num]: paths_to_apply.extend(self.player_inputs[p_num]) self.player_inputs = [[] for i in range(PLAYERS)] self.ready = False self.environment.update_troops(paths_to_apply) self.ready = True try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() wod_client.py: import simple_socket import pygame import json from constants import * def interp(threshold, a, b): if a == b: return 0.5 t = (threshold - a) / (b - a) return max(0.0, min(1.0, t)) def marching_squares(grid, cell_size, rows, cols, threshold): segments = [] cs = cell_size for j in range(rows): for i in range(cols): c0 = grid[j][i] c3 = grid[j][i + 1] c2 = grid[j + 1][i + 1] c1 = grid[j + 1][i] p_top = interp(threshold, c0, c1) p_right = interp(threshold, c1, c2) p_bottom = interp(threshold, c3, c2) p_left = interp(threshold, c0, c3) x = j * cs y = i * cs p0 = (x + p_top * cs, y) p1 = (x + cs, y + p_right * cs) p2 = (x + p_bottom * cs, y + cs) p3 = (x, y + p_left * cs) idx = 0 if c0 > threshold: idx |= 1 if c1 > threshold: idx |= 2 if c2 > threshold: idx |= 4 if c3 > threshold: idx |= 8 if idx == 0 or idx == 15: pass elif idx == 1: segments.append((p3, p0)) elif idx == 2: segments.append((p0, p1)) elif idx == 3: segments.append((p3, p1)) elif idx == 4: segments.append((p1, p2)) elif idx == 5: segments.append((p3, p0)) segments.append((p1, p2)) elif idx == 6: segments.append((p0, p2)) elif idx == 7: segments.append((p3, p2)) elif idx == 8: segments.append((p2, p3)) elif idx == 9: segments.append((p0, p2)) elif idx == 10: segments.append((p0, p1)) segments.append((p2, p3)) elif idx == 11: segments.append((p1, p2)) elif idx == 12: segments.append((p1, p3)) elif idx == 13: segments.append((p0, p1)) elif idx == 14: segments.append((p3, p0)) return segments def marching_squares_poly(grid, cell_size, rows, cols, threshold): polys = [] cs = cell_size thr = threshold for i in range(rows): for j in range(cols): c0 = grid[i][j] c1 = grid[i][j + 1] c2 = grid[i + 1][j + 1] c3 = grid[i + 1][j] row_pos = i * cs col_pos = j * cs v0 = (row_pos, col_pos) v1 = (row_pos, col_pos + cs) v2 = (row_pos + cs, col_pos + cs) v3 = (row_pos + cs, col_pos) p_top = (row_pos, col_pos + interp(threshold, c0, c1) * cs) p_right = (row_pos + interp(threshold, c1, c2) * cs, col_pos + cs) p_bottom = (row_pos + cs, col_pos + interp(threshold, c3, c2) * cs) p_left = (row_pos + interp(threshold, c0, c3) * cs, col_pos) inside = [c0 > thr, c1 > thr, c2 > thr, c3 > thr] idx = 0 if inside[0]: idx |= 1 if inside[1]: idx |= 2 if inside[2]: idx |= 4 if inside[3]: idx |= 8 if idx == 0: continue if idx == 15: polys.append([v0, v1, v2, v3]) continue pts = { "v0": v0, "v1": v1, "v2": v2, "v3": v3, "p_top": p_top, "p_right": p_right, "p_bottom": p_bottom, "p_left": p_left, } specs = TABLE.get(idx, []) for spec in specs: poly = [pts[name] for name in spec] compact = [] for p in poly: if not compact or (abs(p[0] - compact[-1][0]) > 1e-9 or abs(p[1] - compact[-1][1]) > 1e-9): compact.append(p) if len(compact) >= 3: polys.append(compact) return polys def marching_squares_layers(grid, cell_size, rows, cols, thresholds): layers = [] for thr in thresholds: threshold = thr polys = marching_squares_poly(grid, cell_size, rows, cols, threshold) layers.append(polys) return layers class Game: def __init__(self, title): pygame.init() info_object = pygame.display.Info() desktop_width = info_object.current_w desktop_height = info_object.current_h self.size = (desktop_width - 20, desktop_height - 100) self.factor = min(self.size[0] / WORLD_X, self.size[1] / WORLD_Y) self.screen = pygame.display.set_mode(self.size) pygame.display.set_caption(title) pygame.event.set_allowed( [ pygame.KEYDOWN, pygame.QUIT, pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION, pygame.MOUSEWHEEL, ] ) self.clock = pygame.time.Clock() self.done = False self.zoom_levels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3.5, 4, 6] self.zoom_idx = self.zoom_levels.index(1) self.zoom = self.get_zoom(self.zoom_idx) self.camx, self.camy = 0.0, 0.0 self.panning = False self.pan_start_mouse = (0, 0) self.pan_start_cam = (0.0, 0.0) self.draw_info = None self.player_input = [[], []] self.paths = [] self.drawing_path = False self.city_paths = [] self.drawing_city_path = False self.pause = False self.terrain_by_zoom = {} def run_game(self): ip, port = input("ip\n: "), input("\nport\n: ") print("connecting...") self.client = simple_socket.Client(ip, PORTS[min(99, max(0, int(port)))]) self.client.connect() print("connection successful!") print("drawing terrain...") terrain_grid, forrest_grid, cities, self.player_num = json.loads(self.client.rcv()) self.color = COLORS[self.player_num] layers = marching_squares_layers(terrain_grid, CELL_SIZE, ROWS, COLS, list(TERRAIN_VALUES.values())) layers.append(marching_squares_poly(forrest_grid, CELL_SIZE, ROWS, COLS, THRESHOLD)) for i in range(len(self.zoom_levels)): z = self.get_zoom(i) sw = max(1, int(WORLD_X * z)) sh = max(1, int(WORLD_Y * z)) surf = pygame.Surface((sw, sh), pygame.SRCALPHA) for poly in layers[0]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (0, 220, 255), scaled, 0) for poly in layers[1]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (20, 180, 20), scaled, 0) for poly in layers[2]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (150, 150, 150), scaled, 0) for poly in layers[3]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (100, 100, 100), scaled, 0) for poly in layers[4]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (30, 125, 30), scaled, 0) for position in cities: if position is None: continue cx, cy = int(position[0] * z), int(position[1] * z) pygame.draw.circle(surf, (255, 215, 0), (cx, cy), max(1, int(15 * z))) self.terrain_by_zoom[z] = surf print("terrain drawn! starting game (waiting for other players)...") self.draw_info = json.loads(self.client.rcv()) while not self.done: self.handle_events() self.draw() pygame.display.flip() self.clock.tick(30) self.client.close() pygame.quit() def handle_events(self): if not self.pause: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.MOUSEBUTTONDOWN: if e.button == 3 and not self.drawing_path and not self.drawing_city_path: self.panning = True self.pan_start_mouse = e.pos self.pan_start_cam = (self.camx, self.camy) elif e.button == 4 and not self.drawing_path and not self.drawing_city_path: self.zoom_in_at(e.pos) elif e.button == 5 and not self.drawing_path and not self.drawing_city_path: self.zoom_out_at(e.pos) elif e.button == 1: mx, my = e.pos[0], e.pos[1] troops = self.draw_info[2] r = max(1, int(7 * self.zoom)) r3 = r * 3 best = None best_dist2 = None best_pos = None for pos, color, tid, owner, path, health in troops: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best is None or d2 < best_dist2: best_pos = pos best = tid best_dist2 = d2 if best is not None: self.drawing_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.paths): if id_path[0] == best: to_pop = i if to_pop is not None: self.paths.pop(to_pop) self.paths.append((best, [best_pos])) else: mx, my = e.pos[0], e.pos[1] cities = self.draw_info[3] r = max(1, int(7 * self.zoom)) r3 = r * 3 best_city = None best_dist2 = None best_pos = None for color, pos, cid, path, owner in cities: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best_city is None or d2 < best_dist2: best_pos = pos best_city = cid best_dist2 = d2 if best_city is not None: self.drawing_city_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.city_paths): if id_path[0] == best_city: to_pop = i if to_pop is not None: self.city_paths.pop(to_pop) self.city_paths.append((best_city, [best_pos])) elif e.type == pygame.MOUSEBUTTONUP: if e.button == 3: self.panning = False if e.button == 1: self.drawing_path = False self.drawing_city_path = False elif e.type == pygame.MOUSEMOTION: if self.drawing_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.paths[-1][1].append((wx, wy)) elif self.drawing_city_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.city_paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.city_paths[-1][1].append((wx, wy)) elif self.panning: mx, my = e.pos sx, sy = self.pan_start_mouse dx = (mx) - sx dy = (my) - sy self.camx = self.pan_start_cam[0] - dx / self.zoom self.camy = self.pan_start_cam[1] - dy / self.zoom self.clamp_camera() elif e.type == pygame.MOUSEWHEEL: if not self.drawing_path and not self.drawing_city_path: mx, my = pygame.mouse.get_pos() if e.y > 0: self.zoom_in_at((mx, my)) elif e.y < 0: self.zoom_out_at((mx, my)) elif e.type == pygame.KEYDOWN: if e.key == pygame.K_c: self.paths = [] self.city_paths = [] elif e.key == pygame.K_SPACE: if (not self.drawing_path and not self.drawing_city_path) and (self.paths or self.city_paths): for id, path in self.paths: path.pop(0) for id, path in self.city_paths: path.pop(0) self.player_input[0] = self.paths self.player_input[1] = self.city_paths self.paths = [] self.city_paths = [] elif e.key == pygame.K_p: self.player_input = "pause" self.pause = True else: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.KEYDOWN: if e.key == pygame.K_p: self.player_input = "unpause" self.pause = False self.client.send(json.dumps(self.player_input, separators=(",", ":"))) self.player_input = [[], []] def zoom_in_at(self, screen_pos): if self.zoom_idx < len(self.zoom_levels) - 1: self.set_zoom_index(self.zoom_idx + 1, screen_pos) def zoom_out_at(self, screen_pos): if self.zoom_idx > 0: self.set_zoom_index(self.zoom_idx - 1, screen_pos) def get_zoom(self, zoom_idx): return self.zoom_levels[zoom_idx] * self.factor def set_zoom_index(self, new_idx, screen_pos): old_zoom = self.zoom new_zoom = self.get_zoom(new_idx) sx, sy = screen_pos world_x = self.camx + sx / old_zoom world_y = self.camy + sy / old_zoom self.zoom_idx = new_idx self.zoom = new_zoom self.camx = world_x - sx / new_zoom self.camy = world_y - sy / new_zoom self.clamp_camera() def clamp_camera(self): max_camx = max(0.0, WORLD_X - (self.size[0] / self.zoom)) max_camy = max(0.0, WORLD_Y - (self.size[1] / self.zoom)) if self.camx < 0.0: self.camx = 0.0 if self.camy < 0.0: self.camy = 0.0 if self.camx > max_camx: self.camx = max_camx if self.camy > max_camy: self.camy = max_camy def draw(self): self.screen.fill((255, 255, 255)) vision_grid, border_grid, troops, cities = self.draw_info = json.loads( self.client.rcv() ) z = self.zoom terrain_surf = self.terrain_by_zoom[z] offset_x = int(-self.camx * z) offset_y = int(-self.camy * z) self.screen.blit(terrain_surf, (offset_x, offset_y)) dyn_w = max(1, int(WORLD_X * z)) dyn_h = max(1, int(WORLD_Y * z)) dynamic = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) fog = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) paths_to_draw = [] for color, position, cid, path, owner in cities: if path and owner == self.player_num: path.insert(0, position) paths_to_draw.append(path) if color is not None: px = int(position[0] * z) py = int(position[1] * z) pole_bottom = (px, py) pole_top = (px, int(py - 30 * z)) pygame.draw.line(dynamic, (80, 80, 80), pole_bottom, pole_top, max(1, int(3 * z))) flag_color = tuple(color) if isinstance(color, (list, tuple)) else color fw, fh = int(20 * z), int(14 * z) p1 = (pole_top[0], pole_top[1]) p2 = (pole_top[0] + fw, pole_top[1] + fh // 2) p3 = (pole_top[0], pole_top[1] + fh) pygame.draw.polygon(dynamic, flag_color, [p1, p2, p3]) pygame.draw.polygon(dynamic, (0, 0, 0), [p1, p2, p3], max(1, int(1 * z))) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (240, 180, 0), (px, py), (px2, py2), max(1, int(4 * z))) paths_to_draw = [] tids = [tid for tid, path in self.paths] for pos, color, tid, owner, path, health in troops: px = int(pos[0] * z) py = int(pos[1] * z) r = max(1, int(7 * z)) rgb = color if tid in tids: factor = 0.5 rgb = [max(0, min(255, int(x * factor))) for x in color] if path and owner == self.player_num: path.insert(0, pos) paths_to_draw.append(path) pygame.draw.rect(dynamic, (0, 255, 0), pygame.rect.Rect(px-r, (py-r)-max(1, int(3 * z)), (r*2)*(health/100), max(1, int(3 * z)))) pygame.draw.circle(dynamic, rgb, (px, py), r) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, self.color, (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.city_paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for a, b in marching_squares(border_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): ax = int(a[0] * z) ay = int(a[1] * z) bx = int(b[0] * z) by = int(b[1] * z) pygame.draw.line(fog, (0, 0, 0), (ax, ay), (bx, by), max(1, int(3 * z))) for poly in marching_squares_poly(vision_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(fog, (0, 0, 0, 150), scaled, 0) if self.pause: font = pygame.font.SysFont(None, 48) text_surface = font.render("Pause", False, (0, 0, 0)) fog.blit(text_surface, (10, 10)) self.screen.blit(dynamic, (offset_x, offset_y)) self.screen.blit(fog, (offset_x, offset_y)) game_play = Game("WAR OF DOTS") game_play.run_game() simple_socket.py: import socket ####### https://docs.python.org/3/library/socket.html#socket.socket.sendfile ####### #socket.gethostbyname(str(socket.gethostname()))# HEADER, FORMAT = 64, "utf-8" class Client: def __init__(self, servip, port): self.servip = servip self.port = port def connect(self): self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) ADDR = (self.servip, self.port) self.client.connect(ADDR) def send(self, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) self.client.sendall(send_length) self.client.sendall(message) def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self): self.client.close() class Server: def __init__(self, ip, port): self.ip = ip self.port = port def start(self): self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) ADDR = (self.ip, self.port) self.server.bind(ADDR) self.conns = [] def lsn(self, conns=0): if conns > 0: self.server.listen(conns) else: self.server.listen() def accept(self): conn, addr = self.server.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.conns.append(conn) return conn, addr def send(self, conns, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) for conn in conns: conn.sendall(send_length) conn.sendall(message) def rcv(self, conn): msg_length = conn.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = conn.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self, conn): conn.close() self.conns.remove(conn) constants.py: CELL_SIZE = 20 SIZE = (1280, 700) WORLD_X, WORLD_Y = SIZE ROWS = int(SIZE[0]//CELL_SIZE) COLS = int(SIZE[1]//CELL_SIZE) TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } TROOP_R = 7 THRESHOLD = 0.5 PLAYERS = 2 COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] TABLE = { 1: [["v0", "p_top", "p_left"]], 2: [["v1", "p_right", "p_top"]], 3: [["p_left", "v0", "v1", "p_right"]], 4: [["v2", "p_bottom", "p_right"]], 5: [["v0", "p_top", "p_left"], ["v2", "p_right", "p_bottom"]], 6: [["p_top", "v1", "v2", "p_bottom"]], 7: [["p_left", "v0", "v1", "v2", "p_bottom"]], 8: [["v3", "p_left", "p_bottom"]], 9: [["p_top", "v0", "v3", "p_bottom"]], 10: [["p_top", "v1", "p_right"], ["p_bottom", "v3", "p_left"]], 11: [["v0", "v1", "p_right", "p_bottom", "v3"]], 12: [["p_right", "v2", "v3", "p_left"]], 13: [["v0", "p_top", "p_right", "v2", "v3"]], 14: [["v1", "v2", "v3", "p_left", "p_top"]], } PORTS = [i for i in range(1200, 1300)] Question: Ultimately I am looking for performance because I want to increase map size for more players and not lag the server or clients. Any other improvements and bug fixes (I hope there are none!) are welcome. Gameplay suggestions are especially welcome if you actually play it! EDIT: Also I have found a few bugs; the zoom controls are bugged and the bottom left and top right city selections were wrong so ignore them for now pls. oh! and the brush apply function doesn't go to the bottom and right edges. Bounties: I will be placing bounties, to reward the efforts people go to to review this code beyond the accepted answer. pythonperformancepygamebattle-simulation Share Follow edited Feb 18 at 0:41 asked Feb 3 at 10:40 coder's user avatar coder 31911 silver badge2424 bronze badges 2 You really made me watch trailer/videos about war of dots and you image seems quite like the original. I'd hope to find time for this question, it really looks fun. – Thingamabobs CommentedFeb 4 at 4:16 1 Not exactly the feedback you were looking for, but this very much looks like a golf simulator. We see the holes (only 10 of them, so I guess a short course), the green, the rough, the water traps, the locations of the balls people have been hitting, and I guess a dirt trap? I can't unsee it. Best of luck on the game. – Seth Robertson CommentedFeb 5 at 0:18 @SethRobertson haha thanks, i love how it turned out visually and hope it clearly displays the game state. – coder CommentedFeb 5 at 1:07 Add a comment 4 Answers Sorted by: Highest score (default) 3 +50 Wow, what a great game! Thank you. I still need to diagnose why pressing "P" to pause causes fatal stacktrace. initial rendezvous Using input() is kind of OK. But you should definitely let me specify "127.0.0.1" and port "0" in sys.argv, with perhaps the host defaulting to localhost or to the value of an optional env var. The whole business of discussing "port 0" with the user, and then biasing it by 1200 during the bind() call, is needlessly confusing. Just tell the user that ports like 1200 and 1201 will be used. Better yet, make it automatic. The user specifies a hostname, on which server is already running, and the client makes a connection and prints out how many users are already on that server. If zero existing players, then make me 0, and so on. I was a little surprised that all players can connect using same port number, or using distinct port numbers, and in both situations the game works fine. It feels like we're exposing more complexity than strictly necessary. diagnostic Personally, I found getting a ConnectionRefusedError very informative. But you might want a try / except which replaces that with some advice about needing to run the server before running any clients. import PEP-8 asks for three sections of imports, each alphabetically sorted. import json import perlin_noise import math import simple_socket Use isort to accomplish that. Why does it matter? I wound up doing uv add simple_socket and pulling in simple-socket 0.0.10 from pypi, before I eventually noticed your simple_socket.py module. Grouping those libraries tells the Reader where the library came from, and where to go looking for the docs. In your repo you should definitely add a Makefile or similar bash script that shows how you produce a .exe when cutting a release. Consider publishing binaries on a different site, or at least in a different repo, as git really wants to diff text edits in order to avoid bloat. Binaries are incompressible, so a lot of git history can accumulate in short order. You should publish a pyproject.toml file that shows how collaborators should install dependencies, and how you publish to pypi. wildcard from constants import * Uggh! Please don't. Remove the star, note the missing symbols, and use your IDE's auto-complete to fill them in explicitly. As a Reader, when I see star I think "all bets are off", because if I don't recognize an unfamiliar symbol I won't know if it should have matched the wildcard. Even worse when more than one import is wildcarded. Also, I can't grep to see where something came from. tuple unpack In xy_to_dir_dis() we see atan2(xy[1], xy[0]). Please refrain from using those cryptic [1], [0] subscripts which are unrelated to the problem at hand. Prefer to speak plainly: x, y = xy Then you can use a straightforward atan2(y, x) call. Also, 0 - x is weird, where unary -x would suffice. And why are we negating in the first place, given that the squaring will make it positive? Consider introducing Polar and Cartesian coordinate classes, or borrowing them from an existing pypi library or from the builtin Complex type. Consider keeping the internal representation as radians. pointers self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] Wow, that's a lot of 64-bit pointers to (double prec.) float objects. Prefer array, for a more compact representation with less pointer indirection. If nothing else, smaller footprint is more cache friendly. Better still, use an NDArray, if you're willing to take a dep on NumPy. Consider whether 32-bit single precision floats would be adequate, here. meaningful identifier def get_grid_value(self, x, y): ... val = ( ... This is indeed a getter. But value & val are on the vague side; I would love to know the meaning of the returned number. And I'm not too sure we're properly using the word grid here. It feels more like there's a continuous battle field, occasionally punctuated by grid points, and this interpolation helper is performing get_field_potential_from_grid(). (I imagine that each Red and Blue unit on the field imposes a plus or minus potential, and we're reporting the net potential.) A """docstring""" would have gone a long way toward illuminating Author's Intent for what this helper does. edge case Sorry, but when reading x2, ... = min(x1 + 1, ROWS), ..., I'm not quite sure what to make of it. We already have that x1 is an integer. So either x2 is one bigger, or when at the ROWS limit x2 is identical to x1, so "interpolate" works out to "just return the coarse grid value". And we might be at the limit in one direction but not the other. Seems like it's worth tacking on a sentence at the end of the docstring, or at least add a # comment. The docstring should additionally cite https://en.wikipedia.org/wiki/Marching_squares invariants In Brush.apply, I'm a little surprised that we need to verify radius is positive -- not sure what would shrink it. And we could have just initialized at 40.0, without need for a float() call. enum Helpers like get_terrain_name() make it look like we really want to store names and speed values in an Enum. We could then put such mapping helpers within that class. @dataclass get_terrain_info() looks like it wants to return a TerrainInfo dataclass. helpers As you commented, update_troops() clearly needs to break out several helper functions. The closest_city attribute looks like it could be memoized, since cities don't move. (Not sure why they have a path.) For each player and each discretized grid point, compute and store the closest city. Then a troop can use int() to get its grid point, and look up the closest city without looping. There's a lot of magic numbers in there. Am I worried that they're not given names? No, not really. It does worry me that they don't show up in the player documentation, and as a player it's hard for me to visualize those interactions on-screen. I can't tell if a unit I just moved is now in or out of range for one or another of those special cases. In the Game constructor, 1200 certainly deserves a name with PORT in it. mutable defaults def __init__(self, ..., path=None): ... self.path = path if path is not None else [] Thank you for avoiding a mutable Troop default. The usual idiom here is slightly more compact: self.path = path or [] In the general case we'd need what you wrote, if e.g. path could take on a "falsey" value like 0.0. But here it's only the len() of a list that matters. It seems odd you're caching id(self), since you could cheaply ask for it any time you need it. number of players I propose that there's six players, all the time. But initially they're all inactive. And as TCP clients join, they claim the first inactive player. This makes joining a game after some delay more flexible, and reduces the interactive questions we have to ask up front. a boolean variable is already a boolean ... or self.done == True Prefer a simple ... or self.done. Also, looking at {ready, started, done}, perhaps we have more state variables than needed? For example, the "sleep 100 msec till self.started" loop could perhaps be removed if we deferred creating Players and threads until all had joined. Also, I'm not quite understanding self.done. When Blue defeats all Red cities and units, the game continues to play, rather than declaring victory. main guard wod_server.py contains quite a lot of code, and that is fine. But we don't have the traditional if __name__ == "__main__": guard, so automated unit tests cannot safely import this module without undesired side effects, and that is a problem. contract The wod_client interp() function really needs a docstring. What promises is it making to the caller? And marching_squares() needs either a docstring or at least some comments, perhaps a literature citation. Also, in handle_events() and in draw() we need to break out some helpers. "values" TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } Yes, each of those four is a numeric "value". But tell us what those numbers mean. Do they relate to vision? To mobility? COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] Consider defining some Enums or some MANIFEST_CONSTANTS, and then put named colors into that list. gameplay grid markings Distance between units plays an important role in this game. Consider superimposing a purely cosmetic square or hex grid upon the display, to help folks visually judge relative distances. Paper maps often have a "12 miles to the inch" type of legend in the corner, with some black & white markings that let you put your thumb first here, then there, and say it's so many miles to destination. If there's no visible grid, consider putting some standard size markings in a corner legend. within range I have some idea of which units are interacting (killing) which other units. But it's not completely obvious, and I was surprised at how many special cases the server code handles. I feel like there's enough information available to the client module that it could choose to render orange lighting zaps coming from an attacking unit, that sort of thing. To support this, we would need a common library module which both server and client import, describing the damage model. There's two things I care about for a unit: health (damage), and rate of damage (or rate of healing) With the current game display, I have a hard time knowing which units in the fray are actually interacting, and if I have pulled a damaged unit "far enough" away from the melee. There is room to reveal derivative of health, IDK maybe with a simple left/right arrow to show it is dwindling or increasing. random damage After Blue defeats all Red cities and troops, I still observe Blue deaths, and respawns at cities. I cannot explain the damage / death events. update speed We already track FPS. I wouldn't mind seeing a one-line speed report that appears once per minute. It could mention how much usermode CPU we've been using recently. I would love to know about episodes where we asked for an unusually small sleep() time. There's no automated unittest for a server update cycle, and that makes it hard to profile the server code to see where the hot spots are. A test could include a scaling parameter, so we compare elapsed time on small vs. large maps or army sizes. It is often the case that "most" troops lack an active path. During profiling I would want to know if a "keep the status quo" approach is a good fit for such idle troops, so they're updated less often. The marching squares (visibility) calculations could perhaps be scheduled less often than troop updates are, without being visually jarring. Every nth frame might suffice. summary score Maybe display total number of per-player troops? (Or does that only become of interest once a player loses all cities?) Total number of "deployed" (out-of-city) troops? Total deaths? Maybe display the current aggregate damage (or "heal") rate for each player? Maybe a mouse-over on a troop would reveal its damage rate? Could be a tooltip near the pointer, but probably better to bury it in a corner display near the legend, so it's less distracting. Share Follow edited Feb 7 at 16:05 answered Feb 6 at 21:36 J_H's user avatar J_H 46k33 gold badges4141 silver badges167167 bronze badges is the overhead of converting ndarray to list and back for json worth using ndarrays? (great answer btw +1 and likely +50) – coder CommentedFeb 7 at 0:37 1 "overhead of converting ndarray to list and back for json" Surely we don't do that 45 times per second, right? In any event, nobody cares what one person's opinion might be. It sounds like we want to Extract Helper so the pygame loop can call into the same update() that a unit test or timeit() calls into. And then "worth it" is just a matter of comparing one elapsed time against another. // I happened to be playing Blue. I'm happy to play Red until I witness Blue's extinction, and I imagine I will see a similar "Red troops die / respawn even with no enemy troops alive" effect. – J_H CommentedFeb 7 at 1:35 do what you like with testing its your time/answer || ill look into sending numpy info, it probably will end up worth doing for performance because the brush apply could be improved with numpy for sure, the main performance issue it seems rn 1269497 72.511 0.000 115.930 0.000 wod_server.py:58(apply) – coder CommentedFeb 7 at 1:46 1 Ok, issue 2 shows the relevant screenshot for "unexpected damage". Automated unit tests would help with narrowing this down. – J_H CommentedFeb 7 at 4:26 Add a comment 7 +100 UX When I run the wod_server.py code, I get this prompt: Enter number of players (2-6): That is easy to understand. Then I get this prompt: Enter port to use (0 - 99): But, I am not sure what the numbers mean. The prompt could be a little more specific. It would be better to print a few lines of information before the prompts, giving the user more context. Briefly describe the game to someone who has never played it. You could also offer an option to bypass the introduction when you run the code (for users who have already played it). The code also appears hung for me at this line of output: waiting for players... I don't know how to proceed to actually play the game. Perhaps the code is waiting for someone to run the wod_client.py code. If that is the case, it would be good to explicitly state it. When I run wod_client.py, I get a GUI window, but it is just a black screen, and I don't know what to do. Documentation The PEP 8 style guide recommends adding docstrings for classes and functions. Consider using type hints to describe input and return types of the functions to make the code even more self-documenting. Comments Comments are intended to describe the code, not question it: def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? That comment should be deleted. Simpler I believe this line: (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 can be simplified as: xy[0] ** 2 + xy[1] ** 2 Give it a try. Also, instead of math.sqrt, you can simplify further using hypot: def xy_to_dir_dis(xy): return math.degrees(math.atan2(xy[1], xy[0])), math.hypot(xy[0], xy[1]) hypot can also be used in the elevation_bias function. When I see lines like: self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] I wonder if the code could be simplified by using numpy, which may also have performance benefits. Main guard It is customary to add a "main" guard at the end of the code in file wod_server.py: if __name__ == '__main__': try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() Naming PEP 8 only recommends all caps for constants, not variables. In wod_server.py, PLAYERS would be players. Tools You could run code development tools to automatically find some style issues with your code. ruff identifies things like this in wod_server.py: E714 [*] Test for object identity should be `is not` | | self.city_border_brush.apply(player.border, city.position, 1.0) | for other_player in self.players: | if not player is other_player: | ^^^^^^^^^^^^^^^^^^^^^^ E714 | for city in self.cities: | if city.owner is other_player: | = help: Convert to `is not` Share Follow answered Feb 3 at 12:50 toolic's user avatar toolic 21.9k66 gold badges3232 silver badges270270 bronze badges Add a comment 5 Context managers Both your Server and Client classes in simple_socket.py have close functions. I can't help but think that maybe these classes should implement the behaviors of a context manager so you don't necessarily have to explicitly manage this. Boolean comparisons Don't do this. self.done == True Simpler: self.done Early exits In simple_socket.py you have a function: def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" I can't help but think this reads better with an early return in the event msg_length is false. def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if not msg_length: return "" msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) You also never use msg_length except after converting it to an int so rather than repeating yourself: def rcv(self): msg_length = int(self.client.recv(HEADER).decode(FORMAT)) total_received = 0 if not msg_length: return "" msg = [] while total_received < msg_length: data = self.client.recv(msg_length) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) DRY In the following I see a lot of repetition in setting self.players. You may wish to create a list of players once and then assign slices of that list to self.players depending on the value of PLAYERS. if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] Share Follow answered Feb 3 at 15:54 Chris's user avatar Chris 18.2k11 gold badge1414 silver badges9292 bronze badges Add a comment 5 I have not attempted to run this program, so these are just some random thoughts after performing a cursory review. Enum First of all, you deal with tuples a lot, so using namedtuple would make sense at some places. Enums are underutilized as well. For example, instead of: COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] I would suggest this approach: from enum import Enum class Color(Enum): RED = 255, 0, 0 GREEN = 0, 255, 0 BLUE = 0, 0, 255 WHITE = 255, 255, 255 BLACK = 0, 0, 0 print(Color.BLUE) Then you can use expressive color names, that make the code immediately more descriptive. In fact, the parentheses are not needed here, you can remove them. I have not bothered to introduce a full RGB class, this setup is sufficient for your purpose. You could also use namedtuple for coordinates. As shown in another question from collections import namedtuple Point = namedtuple('Point', 'x y', defaults=[0, 0]) I regret the lack of comments. Since the code is fairly long, and without being privy to the game, the flow is not easy to follow without testing the program and studying the rules of the game. From time to time, it would be helpful to comment blocks of code, to explain what you are doing at this specific point and why. Naming Function names are not always intuitive. For example, update_cities does not tell me what exactly we are doing here. Other names like xy_to_dir_dis are somewhat cryptic. Don't be afraid to use longer names, there is no bonus for abbreviating things. To take a random example: def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) You are assigning a new variable cs, which takes the value of CELL_SIZE. Just for the sake of abbreviating. That only obfuscates the code. Likewise, self.radius is much more expressive than just r, which could mean anything. And you could explicitly cast self.radius to float in your __init__ method if that matters. But many variable names are very short, which does not facilitate comprehension, and can even be a cause of bugs in my opinion. Class Some classes like Troop, City, Player should be made @dataclass to reduce boilerplate a little bit. In class Environment, the assignment of self.players is quite dense and there is repeat code. I think it would be interesting to explore dataclass and especially the default_factory method. Suggested article which I found helpful: Python Data Classes: A Comprehensive Tutorial Share Follow answered Feb 3 at 17:37 Kate's user avatar Kate 11k1010 silver badges3030 bronze badges Add a comment You must log in to answer this question. Start asking to get answers Find the answer to your question by asking. Explore related questions pythonperformancepygamebattle-simulation See similar questions with these tags. The Overflow Blog Your LLM issues are really data issues Welcome to the “find out” stage of AI Report this ad Report this ad Linked 5 YouTube Downloader implementation using CustomTkinter and Pytubefix 9 Maze Solver in Python inspired by Micro-Mouse Competition Logic 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Related 2 War card game simulator 12 Script for a Civil War game 9 Simple top down shooter game 3 Python 4 Players Snake Game 6 Python/Pygame Fighting Game 5 Simple Python Pygame Game 2 Card game of war using pygame module 5 Very simple Flappy 'Bird' game - First project in Python 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Hot Network Questions Which type of barrel adjuster is missing here? First book of series might leave a bad impression; rewrite or not? What precedent did the 2 Live Crew case actually set? How can we obtain a smoother sphere when cutting it with a plane and moving the cut portion? Non-quasi-separated perfectoid spaces more hot questions Question feed Code Review Tour Help Chat Contact Feedback Company Stack Overflow Stack Internal Stack Data Licensing Stack Ads About Press Legal Privacy Policy Terms of Service Your Privacy Choices Cookie Policy Stack Exchange Network Technology Culture & recreation Life & arts Science Professional Business API Data Blog Facebook Twitter LinkedIn Instagram Site design / logo © 2026 Stack Exchange Inc; user contributions licensed under CC BY-SA . rev 2026.4.23.42490 import json import perlin_noise import math import simple_socket import socket import time import threading import random from constants import * def dir_dis_to_xy(direction, distance): return ( (distance * math.cos(math.radians(direction))), (distance * math.sin(math.radians(direction))), ) def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? return math.degrees(math.atan2(xy[1], xy[0])), math.sqrt( (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 ) class MarchingSquares: def __init__(self): self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] def set_grid(self, new_grid): self.grid = new_grid def get_grid_value(self, x, y): x1, y1 = int(x), int(y) x2, y2 = min(x1 + 1, ROWS), min(y1 + 1, COLS) dx, dy = x - x1, y - y1 p11 = self.grid[x1][y1] p21 = self.grid[x2][y1] p12 = self.grid[x1][y2] p22 = self.grid[x2][y2] val = ( p11 * (1 - dx) * (1 - dy) + p21 * dx * (1 - dy) + p12 * (1 - dx) * dy + p22 * dx * dy ) return val class Brush: def __init__(self, radius=40, strength=1.0, falloff=1.0): self.radius = radius self.strength = strength self.falloff = falloff def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) inv_r = 1.0 / r grid = marching_squares.grid strength = self.strength falloff = self.falloff for j in range(row_start, row_end): px = j * cs dx_sq = (px - mx) ** 2 row = grid[j] for i in range(col_start, col_end): py = i * cs dy = py - my dist_sq = dy * dy + dx_sq if dist_sq <= r * r: dist = math.sqrt(dist_sq) t = dist * inv_r weight = strength + t * (falloff - strength) old = row[i] row[i] = max(0.0, min(1.0, old + (target_value - old) * weight)) class Environment: def __init__(self): self.terrain_speeds = { "water": 0.6, "forest": 0.8, "plains": 1, "hill": 0.7, "mountain": 3, } self.terrain_attacks = { "water": 0.5, "forest": 0.75, "plains": 1, "hill": 1.5, "mountain": 0, } self.terrain_marching = MarchingSquares() self.forest_marching = MarchingSquares() self.cities = [] self.default_vision = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] for y in range(COLS + 1): for x in range(ROWS + 1): self.default_vision[x][y] = 0.0 self.generate_terrain() self.generate_default_vision() # 2 players left and right most cities # 3 players left-bottom, top, right-bottom # 4 players left-bottom, top-left, top-right, right-bottom # 5 players left-bottom, top-left, middle, top-right, right-bottom # 6 players left-bottom, top-left, middle-left, middle-right, top-right, right-bottom left_bottom_city = min(self.cities, key=lambda c: c.position[0] + c.position[1]) top_left_city = min(self.cities, key=lambda c: c.position[0] - c.position[1]) middle_top_city = min( self.cities, key=lambda c: (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5) + c.position[1], ) middle_bottom_city = max( self.cities, key=lambda c: c.position[1] - (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5), ) top_right_city = max(self.cities, key=lambda c: c.position[0] - c.position[1]) right_bottom_city = max( self.cities, key=lambda c: c.position[0] + c.position[1] ) left_city = min(self.cities, key=lambda c: c.position[0]) right_city = max(self.cities, key=lambda c: c.position[0]) top_city = max(self.cities, key=lambda c: c.position[1]) middle_city = min( self.cities, key=lambda c: abs(c.position[0] - (ROWS * CELL_SIZE) / 2) + abs(c.position[1] - (COLS * CELL_SIZE) / 2), ) if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] self.vision_brush = Brush(75, 1, 0) self.city_vision_brush = Brush(175, 1, 0) self.border_brush = Brush(40, 0.05, 0) self.city_border_brush = Brush(80, 0.05, 0) self.players_in_cities = [[] for _ in self.cities] def generate_terrain(self): def elevation_bias(x, y): cx = ROWS / 2 cy = COLS / 2 dx = abs(x - cx) dy = abs(y - cy) dist = math.sqrt((dx) ** 2 + (dy) ** 2) max_dist = math.sqrt((cx) ** 2 + (cy) ** 2) return 1.0 - (dist / max_dist) noise = perlin_noise.PerlinNoise(octaves=3) for y in range(COLS + 1): for x in range(ROWS + 1): value = max( 0, min(1, ((noise([x / 25, y / 25])) - 0.2) + (elevation_bias(x, y))), ) self.terrain_marching.grid[x][y] = value forest_noise = perlin_noise.PerlinNoise(octaves=1.1) for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] value = (min(0.6, forest_noise([x / 30, y / 30])) * 2.0) + 0.3 plains_diff = max(0, (TERRAIN_VALUES["plains"] + 0.1) - terrain_value) hill_diff = max(0, terrain_value - (TERRAIN_VALUES["hill"] - 0.1)) self.forest_marching.grid[x][y] = ( value - (plains_diff * 10) ) - hill_diff * 10 def within_edges(cx, cy): edge_margin = int(1) return ( cx >= edge_margin and cx <= ROWS - edge_margin and cy >= edge_margin and cy <= COLS - edge_margin ) tries = 0 distance = 15 while True: cx = random.randint(0, ROWS) cy = random.randint(0, COLS) terrain_value = self.terrain_marching.grid[cx][cy] if ( ( terrain_value > TERRAIN_VALUES["plains"] and terrain_value < TERRAIN_VALUES["hill"] ) and all( abs(cx * CELL_SIZE - city.position[0]) + abs(cy * CELL_SIZE - city.position[1]) >= CELL_SIZE * distance for city in self.cities ) and within_edges(cx, cy) and self.forest_marching.grid[cx][cy] < THRESHOLD ): px = cx * CELL_SIZE py = cy * CELL_SIZE self.cities.append(City((px, py))) distance = 15 if len(self.cities) >= 10: break tries += 1 if tries >= 100: distance = max(2, distance - 2) tries = 0 def generate_default_vision(self): for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] forest_value = self.forest_marching.grid[x][y] self.default_vision[x][y] = 0.35 + ( max(min((((terrain_value + 0.1) / 1) + 0.2), 1), 0.2) + (0.8 if forest_value > 0.6 else 0.0) ) def draw_info(self, player): ply = self.players[player] vision_grid = ply.vision.grid border_grid = ply.border.grid troops = [] cities = [ ( c.owner.color if c.owner is not None else None, c.position, c.id, c.path, self.players.index(c.owner) if c.owner is not None else -1, ) for c in self.cities ] for troop in [t for p in self.players for t in p.troops]: ply = self.players[player] vision = ply.vision px, py = troop.position gx = px / CELL_SIZE gy = py / CELL_SIZE gx = max(0, min(ROWS, gx)) gy = max(0, min(COLS, gy)) if vision.get_grid_value(gx, gy) < THRESHOLD: troops.append( ( troop.position, troop.owner.color, troop.id, self.players.index(troop.owner), troop.path, troop.health, ) ) return vision_grid, border_grid, troops, cities def get_terrain_info(self): return ( self.terrain_marching.grid, self.forest_marching.grid, [c.position for c in self.cities], ) def get_terrain_name(self, value, fvalue): if fvalue > THRESHOLD: return "forest" for name, v in reversed(TERRAIN_VALUES.items()): if value > v: return name def update_troops(self, paths_to_apply): # split into more functions ? self.players_in_cities = [[] for _ in self.cities] troop_ids = [info[0] for info in paths_to_apply] troop_paths = [info[1] for info in paths_to_apply] for player in self.players: player.vision.grid = [row[:] for row in self.default_vision] for city in self.cities: if city.owner is player: self.city_vision_brush.apply(player.vision, city.position, 0) self.city_border_brush.apply(player.border, city.position, 1.0) for other_player in self.players: if not player is other_player: for city in self.cities: if city.owner is other_player: self.city_border_brush.apply( player.border, city.position, 0.0 ) to_remove = [] for troop in player.troops: if troop.health <= 0: to_remove.append(troop) continue try: tidx = troop_ids.index(id(troop)) troop.path = troop_paths[tidx] except ValueError: pass old_pos = troop.position owned = [city.position for city in self.cities if city.owner is player] if owned: closest_city = min( owned, key=lambda x: xy_to_dir_dis( ((old_pos[0] - x[0]), (old_pos[1] - x[1])) ), ) city_dir, city_dist = xy_to_dir_dis( ((old_pos[0] - closest_city[0]), (old_pos[1] - closest_city[1])) ) sample_points = [ dir_dis_to_xy(city_dir, dist * 20) for dist in range(int(city_dist // 20)) ] border_avg = 0 if sample_points: border_avgs = [] for other_player in self.players: if other_player is not player: border_avgs.append( sum( [ other_player.border.get_grid_value( (closest_city[0] + s_p[0]) / CELL_SIZE, (closest_city[1] + s_p[1]) / CELL_SIZE, ) for s_p in sample_points ] ) / len(sample_points) ) border_avg = sum(border_avgs) / len(border_avgs) dist_penal = max(((city_dist + 250) / 1000), 0.5) healing_power = (1 - (border_avg / 2)) - dist_penal else: healing_power = -0.5 troop.health += healing_power / 25 if troop.health > 100: troop.health = 100 enemies_in_range = [] gx = old_pos[0] / CELL_SIZE gy = old_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) on_terrain = self.get_terrain_name(terrain, forest) if troop.path: target = troop.path[0] terrain_speed = self.terrain_speeds[on_terrain] dir, distance = xy_to_dir_dis( (target[0] - old_pos[0], target[1] - old_pos[1]) ) distance = terrain_speed * 0.1 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) new_pos = (old_pos[0] + new_off_x, old_pos[1] + new_off_y) for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 14: distance = 14 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain dir, distance = xy_to_dir_dis( (target[0] - troop.position[0], target[1] - troop.position[1]) ) if distance < (terrain_speed * 2): troop.path.pop(0) else: new_pos = old_pos for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 15: distance += 0.025 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain if enemies_in_range: attack_power = self.terrain_attacks[on_terrain] / 25 closest = min(enemies_in_range, key=lambda x: x[1]) closest[0].health -= attack_power if on_terrain == "hill": self.city_vision_brush.apply(player.vision, troop.position, 0) else: self.vision_brush.apply(player.vision, troop.position, 0) self.border_brush.apply(player.border, troop.position, 1.0) for i, city in enumerate(self.cities): cx, cy = city.position tx, ty = troop.position dir, dist = xy_to_dir_dis((tx - cx, ty - cy)) if dist < 15: self.players_in_cities[i].append(player) break to_remove.reverse() for t in to_remove: player.troops.remove(t) def update_cities(self, paths_to_apply): city_ids = [info[0] for info in paths_to_apply] city_paths = [info[1] for info in paths_to_apply] for i, city in enumerate(self.cities): try: cidx = city_ids.index(id(city)) city.path = city_paths[cidx] except ValueError: pass cx, cy = city.position last_owner = city.owner if len(self.players_in_cities[i]) == 1: city.owner = self.players_in_cities[i][0] if last_owner is not city.owner: city.timer = 0 city.path = [] if city.owner is not None: city.timer += 1 t_per_c = len(city.owner.troops) / len( [c for c in self.cities if c.owner == city.owner] ) if city.timer >= 45 * (30 * t_per_c) and t_per_c < 10: city.owner.troops.append( Troop( ( cx + random.randrange(-6, 6), cy + random.randrange(-6, 6), ), city.owner, city.path.copy(), ) ) city.timer = 0 class Troop: def __init__(self, position, owner, path=None): self.position = position self.health = 100 self.path = path if path is not None else [] self.owner = owner self.id = id(self) class City: def __init__(self, position): self.position = position self.timer = 0 self.owner = None self.id = id(self) self.path = [] class Player: def __init__(self, start_pos, color, environment): self.start_pos = start_pos self.color = color self.troops = [Troop(self.start_pos, self)] self.border = MarchingSquares() self.vision = MarchingSquares() self.vision.grid = [row[:] for row in environment.default_vision] class Game: def __init__(self): self.FPS = 45 self.last_time = time.perf_counter() self.frame_time = 1 / self.FPS self.done = False self.server = simple_socket.Server( socket.gethostbyname(str(socket.gethostname())), 1200 ) self.environment = Environment() self.player_inputs = [[] for i in range(PLAYERS)] self.player_city_inputs = [[] for i in range(PLAYERS)] self.player_pause_requests = [False for i in range(PLAYERS)] self.started = False def run_game(self): self.ready = True try: port = int(input("Enter port to use (0 - 99): ")) self.server.port = PORTS[max(0, min(99, port))] except ValueError: pass print("ip: ", self.server.ip, ", port: ", self.server.port) print("starting server...") self.server.start() print("waiting for players...") self.server.lsn(conns=PLAYERS) for player_num in range(PLAYERS): conn, addr = self.server.accept() player_thread = threading.Thread( target=self.handle_player, args=(player_num, conn, addr) ) player_thread.start() print("player: ", player_num, " connected") print("All players connected, starting game!") self.started = True while not self.done: if not all(self.player_pause_requests): self.game_logic() current_time = time.perf_counter() delta_time = current_time - self.last_time self.last_time = current_time if delta_time < self.frame_time: time.sleep(self.frame_time - delta_time) # elif delta_time < self.frame_time*0.75: # self.dots = len(self.environment.players[0].troops) # print(self.dots) def handle_player(self, player_number, conn, addr): self.server.send( [conn], json.dumps( ( *self.environment.get_terrain_info(), player_number, ), separators=(",", ":"), ), ) while not self.started: time.sleep(0.1) draw_info = json.dumps([[], [], [], []], separators=(",", ":")) while True: if self.ready: draw_info = json.dumps( self.environment.draw_info(player_number), separators=(",", ":") ) self.server.send([conn], draw_info) else: self.server.send([conn], draw_info) player_in = json.loads(self.server.rcv(conn)) if player_in == "close" or self.done == True: self.done = True self.server.close(conn) print("player: ", player_number, " left") break if player_in: if player_in == "pause": self.player_pause_requests[player_number] = True elif player_in == "unpause": self.player_pause_requests[player_number] = False else: self.player_inputs[player_number].extend(player_in[0]) self.player_city_inputs[player_number].extend(player_in[1]) def game_logic(self): city_paths_to_apply = [] for p_num in range(PLAYERS): if self.player_city_inputs[p_num]: city_paths_to_apply.extend(self.player_city_inputs[p_num]) self.player_city_inputs = [[] for i in range(PLAYERS)] self.environment.update_cities(city_paths_to_apply) paths_to_apply = [] for p_num in range(PLAYERS): if self.player_inputs[p_num]: paths_to_apply.extend(self.player_inputs[p_num]) self.player_inputs = [[] for i in range(PLAYERS)] self.ready = False self.environment.update_troops(paths_to_apply) self.ready = True try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() wod_client.py: import simple_socket import pygame import json from constants import * def interp(threshold, a, b): if a == b: return 0.5 t = (threshold - a) / (b - a) return max(0.0, min(1.0, t)) def marching_squares(grid, cell_size, rows, cols, threshold): segments = [] cs = cell_size for j in range(rows): for i in range(cols): c0 = grid[j][i] c3 = grid[j][i + 1] c2 = grid[j + 1][i + 1] c1 = grid[j + 1][i] p_top = interp(threshold, c0, c1) p_right = interp(threshold, c1, c2) p_bottom = interp(threshold, c3, c2) p_left = interp(threshold, c0, c3) x = j * cs y = i * cs p0 = (x + p_top * cs, y) p1 = (x + cs, y + p_right * cs) p2 = (x + p_bottom * cs, y + cs) p3 = (x, y + p_left * cs) idx = 0 if c0 > threshold: idx |= 1 if c1 > threshold: idx |= 2 if c2 > threshold: idx |= 4 if c3 > threshold: idx |= 8 if idx == 0 or idx == 15: pass elif idx == 1: segments.append((p3, p0)) elif idx == 2: segments.append((p0, p1)) elif idx == 3: segments.append((p3, p1)) elif idx == 4: segments.append((p1, p2)) elif idx == 5: segments.append((p3, p0)) segments.append((p1, p2)) elif idx == 6: segments.append((p0, p2)) elif idx == 7: segments.append((p3, p2)) elif idx == 8: segments.append((p2, p3)) elif idx == 9: segments.append((p0, p2)) elif idx == 10: segments.append((p0, p1)) segments.append((p2, p3)) elif idx == 11: segments.append((p1, p2)) elif idx == 12: segments.append((p1, p3)) elif idx == 13: segments.append((p0, p1)) elif idx == 14: segments.append((p3, p0)) return segments def marching_squares_poly(grid, cell_size, rows, cols, threshold): polys = [] cs = cell_size thr = threshold for i in range(rows): for j in range(cols): c0 = grid[i][j] c1 = grid[i][j + 1] c2 = grid[i + 1][j + 1] c3 = grid[i + 1][j] row_pos = i * cs col_pos = j * cs v0 = (row_pos, col_pos) v1 = (row_pos, col_pos + cs) v2 = (row_pos + cs, col_pos + cs) v3 = (row_pos + cs, col_pos) p_top = (row_pos, col_pos + interp(threshold, c0, c1) * cs) p_right = (row_pos + interp(threshold, c1, c2) * cs, col_pos + cs) p_bottom = (row_pos + cs, col_pos + interp(threshold, c3, c2) * cs) p_left = (row_pos + interp(threshold, c0, c3) * cs, col_pos) inside = [c0 > thr, c1 > thr, c2 > thr, c3 > thr] idx = 0 if inside[0]: idx |= 1 if inside[1]: idx |= 2 if inside[2]: idx |= 4 if inside[3]: idx |= 8 if idx == 0: continue if idx == 15: polys.append([v0, v1, v2, v3]) continue pts = { "v0": v0, "v1": v1, "v2": v2, "v3": v3, "p_top": p_top, "p_right": p_right, "p_bottom": p_bottom, "p_left": p_left, } specs = TABLE.get(idx, []) for spec in specs: poly = [pts[name] for name in spec] compact = [] for p in poly: if not compact or (abs(p[0] - compact[-1][0]) > 1e-9 or abs(p[1] - compact[-1][1]) > 1e-9): compact.append(p) if len(compact) >= 3: polys.append(compact) return polys def marching_squares_layers(grid, cell_size, rows, cols, thresholds): layers = [] for thr in thresholds: threshold = thr polys = marching_squares_poly(grid, cell_size, rows, cols, threshold) layers.append(polys) return layers class Game: def __init__(self, title): pygame.init() info_object = pygame.display.Info() desktop_width = info_object.current_w desktop_height = info_object.current_h self.size = (desktop_width - 20, desktop_height - 100) self.factor = min(self.size[0] / WORLD_X, self.size[1] / WORLD_Y) self.screen = pygame.display.set_mode(self.size) pygame.display.set_caption(title) pygame.event.set_allowed( [ pygame.KEYDOWN, pygame.QUIT, pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION, pygame.MOUSEWHEEL, ] ) self.clock = pygame.time.Clock() self.done = False self.zoom_levels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3.5, 4, 6] self.zoom_idx = self.zoom_levels.index(1) self.zoom = self.get_zoom(self.zoom_idx) self.camx, self.camy = 0.0, 0.0 self.panning = False self.pan_start_mouse = (0, 0) self.pan_start_cam = (0.0, 0.0) self.draw_info = None self.player_input = [[], []] self.paths = [] self.drawing_path = False self.city_paths = [] self.drawing_city_path = False self.pause = False self.terrain_by_zoom = {} def run_game(self): ip, port = input("ip\n: "), input("\nport\n: ") print("connecting...") self.client = simple_socket.Client(ip, PORTS[min(99, max(0, int(port)))]) self.client.connect() print("connection successful!") print("drawing terrain...") terrain_grid, forrest_grid, cities, self.player_num = json.loads(self.client.rcv()) self.color = COLORS[self.player_num] layers = marching_squares_layers(terrain_grid, CELL_SIZE, ROWS, COLS, list(TERRAIN_VALUES.values())) layers.append(marching_squares_poly(forrest_grid, CELL_SIZE, ROWS, COLS, THRESHOLD)) for i in range(len(self.zoom_levels)): z = self.get_zoom(i) sw = max(1, int(WORLD_X * z)) sh = max(1, int(WORLD_Y * z)) surf = pygame.Surface((sw, sh), pygame.SRCALPHA) for poly in layers[0]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (0, 220, 255), scaled, 0) for poly in layers[1]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (20, 180, 20), scaled, 0) for poly in layers[2]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (150, 150, 150), scaled, 0) for poly in layers[3]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (100, 100, 100), scaled, 0) for poly in layers[4]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (30, 125, 30), scaled, 0) for position in cities: if position is None: continue cx, cy = int(position[0] * z), int(position[1] * z) pygame.draw.circle(surf, (255, 215, 0), (cx, cy), max(1, int(15 * z))) self.terrain_by_zoom[z] = surf print("terrain drawn! starting game (waiting for other players)...") self.draw_info = json.loads(self.client.rcv()) while not self.done: self.handle_events() self.draw() pygame.display.flip() self.clock.tick(30) self.client.close() pygame.quit() def handle_events(self): if not self.pause: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.MOUSEBUTTONDOWN: if e.button == 3 and not self.drawing_path and not self.drawing_city_path: self.panning = True self.pan_start_mouse = e.pos self.pan_start_cam = (self.camx, self.camy) elif e.button == 4 and not self.drawing_path and not self.drawing_city_path: self.zoom_in_at(e.pos) elif e.button == 5 and not self.drawing_path and not self.drawing_city_path: self.zoom_out_at(e.pos) elif e.button == 1: mx, my = e.pos[0], e.pos[1] troops = self.draw_info[2] r = max(1, int(7 * self.zoom)) r3 = r * 3 best = None best_dist2 = None best_pos = None for pos, color, tid, owner, path, health in troops: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best is None or d2 < best_dist2: best_pos = pos best = tid best_dist2 = d2 if best is not None: self.drawing_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.paths): if id_path[0] == best: to_pop = i if to_pop is not None: self.paths.pop(to_pop) self.paths.append((best, [best_pos])) else: mx, my = e.pos[0], e.pos[1] cities = self.draw_info[3] r = max(1, int(7 * self.zoom)) r3 = r * 3 best_city = None best_dist2 = None best_pos = None for color, pos, cid, path, owner in cities: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best_city is None or d2 < best_dist2: best_pos = pos best_city = cid best_dist2 = d2 if best_city is not None: self.drawing_city_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.city_paths): if id_path[0] == best_city: to_pop = i if to_pop is not None: self.city_paths.pop(to_pop) self.city_paths.append((best_city, [best_pos])) elif e.type == pygame.MOUSEBUTTONUP: if e.button == 3: self.panning = False if e.button == 1: self.drawing_path = False self.drawing_city_path = False elif e.type == pygame.MOUSEMOTION: if self.drawing_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.paths[-1][1].append((wx, wy)) elif self.drawing_city_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.city_paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.city_paths[-1][1].append((wx, wy)) elif self.panning: mx, my = e.pos sx, sy = self.pan_start_mouse dx = (mx) - sx dy = (my) - sy self.camx = self.pan_start_cam[0] - dx / self.zoom self.camy = self.pan_start_cam[1] - dy / self.zoom self.clamp_camera() elif e.type == pygame.MOUSEWHEEL: if not self.drawing_path and not self.drawing_city_path: mx, my = pygame.mouse.get_pos() if e.y > 0: self.zoom_in_at((mx, my)) elif e.y < 0: self.zoom_out_at((mx, my)) elif e.type == pygame.KEYDOWN: if e.key == pygame.K_c: self.paths = [] self.city_paths = [] elif e.key == pygame.K_SPACE: if (not self.drawing_path and not self.drawing_city_path) and (self.paths or self.city_paths): for id, path in self.paths: path.pop(0) for id, path in self.city_paths: path.pop(0) self.player_input[0] = self.paths self.player_input[1] = self.city_paths self.paths = [] self.city_paths = [] elif e.key == pygame.K_p: self.player_input = "pause" self.pause = True else: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.KEYDOWN: if e.key == pygame.K_p: self.player_input = "unpause" self.pause = False self.client.send(json.dumps(self.player_input, separators=(",", ":"))) self.player_input = [[], []] def zoom_in_at(self, screen_pos): if self.zoom_idx < len(self.zoom_levels) - 1: self.set_zoom_index(self.zoom_idx + 1, screen_pos) def zoom_out_at(self, screen_pos): if self.zoom_idx > 0: self.set_zoom_index(self.zoom_idx - 1, screen_pos) def get_zoom(self, zoom_idx): return self.zoom_levels[zoom_idx] * self.factor def set_zoom_index(self, new_idx, screen_pos): old_zoom = self.zoom new_zoom = self.get_zoom(new_idx) sx, sy = screen_pos world_x = self.camx + sx / old_zoom world_y = self.camy + sy / old_zoom self.zoom_idx = new_idx self.zoom = new_zoom self.camx = world_x - sx / new_zoom self.camy = world_y - sy / new_zoom self.clamp_camera() def clamp_camera(self): max_camx = max(0.0, WORLD_X - (self.size[0] / self.zoom)) max_camy = max(0.0, WORLD_Y - (self.size[1] / self.zoom)) if self.camx < 0.0: self.camx = 0.0 if self.camy < 0.0: self.camy = 0.0 if self.camx > max_camx: self.camx = max_camx if self.camy > max_camy: self.camy = max_camy def draw(self): self.screen.fill((255, 255, 255)) vision_grid, border_grid, troops, cities = self.draw_info = json.loads( self.client.rcv() ) z = self.zoom terrain_surf = self.terrain_by_zoom[z] offset_x = int(-self.camx * z) offset_y = int(-self.camy * z) self.screen.blit(terrain_surf, (offset_x, offset_y)) dyn_w = max(1, int(WORLD_X * z)) dyn_h = max(1, int(WORLD_Y * z)) dynamic = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) fog = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) paths_to_draw = [] for color, position, cid, path, owner in cities: if path and owner == self.player_num: path.insert(0, position) paths_to_draw.append(path) if color is not None: px = int(position[0] * z) py = int(position[1] * z) pole_bottom = (px, py) pole_top = (px, int(py - 30 * z)) pygame.draw.line(dynamic, (80, 80, 80), pole_bottom, pole_top, max(1, int(3 * z))) flag_color = tuple(color) if isinstance(color, (list, tuple)) else color fw, fh = int(20 * z), int(14 * z) p1 = (pole_top[0], pole_top[1]) p2 = (pole_top[0] + fw, pole_top[1] + fh // 2) p3 = (pole_top[0], pole_top[1] + fh) pygame.draw.polygon(dynamic, flag_color, [p1, p2, p3]) pygame.draw.polygon(dynamic, (0, 0, 0), [p1, p2, p3], max(1, int(1 * z))) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (240, 180, 0), (px, py), (px2, py2), max(1, int(4 * z))) paths_to_draw = [] tids = [tid for tid, path in self.paths] for pos, color, tid, owner, path, health in troops: px = int(pos[0] * z) py = int(pos[1] * z) r = max(1, int(7 * z)) rgb = color if tid in tids: factor = 0.5 rgb = [max(0, min(255, int(x * factor))) for x in color] if path and owner == self.player_num: path.insert(0, pos) paths_to_draw.append(path) pygame.draw.rect(dynamic, (0, 255, 0), pygame.rect.Rect(px-r, (py-r)-max(1, int(3 * z)), (r*2)*(health/100), max(1, int(3 * z)))) pygame.draw.circle(dynamic, rgb, (px, py), r) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, self.color, (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.city_paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for a, b in marching_squares(border_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): ax = int(a[0] * z) ay = int(a[1] * z) bx = int(b[0] * z) by = int(b[1] * z) pygame.draw.line(fog, (0, 0, 0), (ax, ay), (bx, by), max(1, int(3 * z))) for poly in marching_squares_poly(vision_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(fog, (0, 0, 0, 150), scaled, 0) if self.pause: font = pygame.font.SysFont(None, 48) text_surface = font.render("Pause", False, (0, 0, 0)) fog.blit(text_surface, (10, 10)) self.screen.blit(dynamic, (offset_x, offset_y)) self.screen.blit(fog, (offset_x, offset_y)) game_play = Game("WAR OF DOTS") game_play.run_game() simple_socket.py: import socket ####### https://docs.python.org/3/library/socket.html#socket.socket.sendfile ####### #socket.gethostbyname(str(socket.gethostname()))# HEADER, FORMAT = 64, "utf-8" class Client: def __init__(self, servip, port): self.servip = servip self.port = port def connect(self): self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) ADDR = (self.servip, self.port) self.client.connect(ADDR) def send(self, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) self.client.sendall(send_length) self.client.sendall(message) def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self): self.client.close() class Server: def __init__(self, ip, port): self.ip = ip self.port = port def start(self): self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) ADDR = (self.ip, self.port) self.server.bind(ADDR) self.conns = [] def lsn(self, conns=0): if conns > 0: self.server.listen(conns) else: self.server.listen() def accept(self): conn, addr = self.server.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.conns.append(conn) return conn, addr def send(self, conns, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) for conn in conns: conn.sendall(send_length) conn.sendall(message) def rcv(self, conn): msg_length = conn.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = conn.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self, conn): conn.close() self.conns.remove(conn) constants.py: CELL_SIZE = 20 SIZE = (1280, 700) WORLD_X, WORLD_Y = SIZE ROWS = int(SIZE[0]//CELL_SIZE) COLS = int(SIZE[1]//CELL_SIZE) TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } TROOP_R = 7 THRESHOLD = 0.5 PLAYERS = 2 COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] TABLE = { 1: [["v0", "p_top", "p_left"]], 2: [["v1", "p_right", "p_top"]], 3: [["p_left", "v0", "v1", "p_right"]], 4: [["v2", "p_bottom", "p_right"]], 5: [["v0", "p_top", "p_left"], ["v2", "p_right", "p_bottom"]], 6: [["p_top", "v1", "v2", "p_bottom"]], 7: [["p_left", "v0", "v1", "v2", "p_bottom"]], 8: [["v3", "p_left", "p_bottom"]], 9: [["p_top", "v0", "v3", "p_bottom"]], 10: [["p_top", "v1", "p_right"], ["p_bottom", "v3", "p_left"]], 11: [["v0", "v1", "p_right", "p_bottom", "v3"]], 12: [["p_right", "v2", "v3", "p_left"]], 13: [["v0", "p_top", "p_right", "v2", "v3"]], 14: [["v1", "v2", "v3", "p_left", "p_top"]], } PORTS = [i for i in range(1200, 1300)] Question: Ultimately I am looking for performance because I want to increase map size for more players and not lag the server or clients. Any other improvements and bug fixes (I hope there are none!) are welcome. Gameplay suggestions are especially welcome if you actually play it! EDIT: Also I have found a few bugs; the zoom controls are bugged and the bottom left and top right city selections were wrong so ignore them for now pls. oh! and the brush apply function doesn't go to the bottom and right edges. Bounties: I will be placing bounties, to reward the efforts people go to to review this code beyond the accepted answer. pythonperformancepygamebattle-simulation Share Follow edited Feb 18 at 0:41 asked Feb 3 at 10:40 coder's user avatar coder 31911 silver badge2424 bronze badges 2 You really made me watch trailer/videos about war of dots and you image seems quite like the original. I'd hope to find time for this question, it really looks fun. – Thingamabobs CommentedFeb 4 at 4:16 1 Not exactly the feedback you were looking for, but this very much looks like a golf simulator. We see the holes (only 10 of them, so I guess a short course), the green, the rough, the water traps, the locations of the balls people have been hitting, and I guess a dirt trap? I can't unsee it. Best of luck on the game. – Seth Robertson CommentedFeb 5 at 0:18 @SethRobertson haha thanks, i love how it turned out visually and hope it clearly displays the game state. – coder CommentedFeb 5 at 1:07 Add a comment 4 Answers Sorted by: Highest score (default) 3 +50 Wow, what a great game! Thank you. I still need to diagnose why pressing "P" to pause causes fatal stacktrace. initial rendezvous Using input() is kind of OK. But you should definitely let me specify "127.0.0.1" and port "0" in sys.argv, with perhaps the host defaulting to localhost or to the value of an optional env var. The whole business of discussing "port 0" with the user, and then biasing it by 1200 during the bind() call, is needlessly confusing. Just tell the user that ports like 1200 and 1201 will be used. Better yet, make it automatic. The user specifies a hostname, on which server is already running, and the client makes a connection and prints out how many users are already on that server. If zero existing players, then make me 0, and so on. I was a little surprised that all players can connect using same port number, or using distinct port numbers, and in both situations the game works fine. It feels like we're exposing more complexity than strictly necessary. diagnostic Personally, I found getting a ConnectionRefusedError very informative. But you might want a try / except which replaces that with some advice about needing to run the server before running any clients. import PEP-8 asks for three sections of imports, each alphabetically sorted. import json import perlin_noise import math import simple_socket Use isort to accomplish that. Why does it matter? I wound up doing uv add simple_socket and pulling in simple-socket 0.0.10 from pypi, before I eventually noticed your simple_socket.py module. Grouping those libraries tells the Reader where the library came from, and where to go looking for the docs. In your repo you should definitely add a Makefile or similar bash script that shows how you produce a .exe when cutting a release. Consider publishing binaries on a different site, or at least in a different repo, as git really wants to diff text edits in order to avoid bloat. Binaries are incompressible, so a lot of git history can accumulate in short order. You should publish a pyproject.toml file that shows how collaborators should install dependencies, and how you publish to pypi. wildcard from constants import * Uggh! Please don't. Remove the star, note the missing symbols, and use your IDE's auto-complete to fill them in explicitly. As a Reader, when I see star I think "all bets are off", because if I don't recognize an unfamiliar symbol I won't know if it should have matched the wildcard. Even worse when more than one import is wildcarded. Also, I can't grep to see where something came from. tuple unpack In xy_to_dir_dis() we see atan2(xy[1], xy[0]). Please refrain from using those cryptic [1], [0] subscripts which are unrelated to the problem at hand. Prefer to speak plainly: x, y = xy Then you can use a straightforward atan2(y, x) call. Also, 0 - x is weird, where unary -x would suffice. And why are we negating in the first place, given that the squaring will make it positive? Consider introducing Polar and Cartesian coordinate classes, or borrowing them from an existing pypi library or from the builtin Complex type. Consider keeping the internal representation as radians. pointers self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] Wow, that's a lot of 64-bit pointers to (double prec.) float objects. Prefer array, for a more compact representation with less pointer indirection. If nothing else, smaller footprint is more cache friendly. Better still, use an NDArray, if you're willing to take a dep on NumPy. Consider whether 32-bit single precision floats would be adequate, here. meaningful identifier def get_grid_value(self, x, y): ... val = ( ... This is indeed a getter. But value & val are on the vague side; I would love to know the meaning of the returned number. And I'm not too sure we're properly using the word grid here. It feels more like there's a continuous battle field, occasionally punctuated by grid points, and this interpolation helper is performing get_field_potential_from_grid(). (I imagine that each Red and Blue unit on the field imposes a plus or minus potential, and we're reporting the net potential.) A """docstring""" would have gone a long way toward illuminating Author's Intent for what this helper does. edge case Sorry, but when reading x2, ... = min(x1 + 1, ROWS), ..., I'm not quite sure what to make of it. We already have that x1 is an integer. So either x2 is one bigger, or when at the ROWS limit x2 is identical to x1, so "interpolate" works out to "just return the coarse grid value". And we might be at the limit in one direction but not the other. Seems like it's worth tacking on a sentence at the end of the docstring, or at least add a # comment. The docstring should additionally cite https://en.wikipedia.org/wiki/Marching_squares invariants In Brush.apply, I'm a little surprised that we need to verify radius is positive -- not sure what would shrink it. And we could have just initialized at 40.0, without need for a float() call. enum Helpers like get_terrain_name() make it look like we really want to store names and speed values in an Enum. We could then put such mapping helpers within that class. @dataclass get_terrain_info() looks like it wants to return a TerrainInfo dataclass. helpers As you commented, update_troops() clearly needs to break out several helper functions. The closest_city attribute looks like it could be memoized, since cities don't move. (Not sure why they have a path.) For each player and each discretized grid point, compute and store the closest city. Then a troop can use int() to get its grid point, and look up the closest city without looping. There's a lot of magic numbers in there. Am I worried that they're not given names? No, not really. It does worry me that they don't show up in the player documentation, and as a player it's hard for me to visualize those interactions on-screen. I can't tell if a unit I just moved is now in or out of range for one or another of those special cases. In the Game constructor, 1200 certainly deserves a name with PORT in it. mutable defaults def __init__(self, ..., path=None): ... self.path = path if path is not None else [] Thank you for avoiding a mutable Troop default. The usual idiom here is slightly more compact: self.path = path or [] In the general case we'd need what you wrote, if e.g. path could take on a "falsey" value like 0.0. But here it's only the len() of a list that matters. It seems odd you're caching id(self), since you could cheaply ask for it any time you need it. number of players I propose that there's six players, all the time. But initially they're all inactive. And as TCP clients join, they claim the first inactive player. This makes joining a game after some delay more flexible, and reduces the interactive questions we have to ask up front. a boolean variable is already a boolean ... or self.done == True Prefer a simple ... or self.done. Also, looking at {ready, started, done}, perhaps we have more state variables than needed? For example, the "sleep 100 msec till self.started" loop could perhaps be removed if we deferred creating Players and threads until all had joined. Also, I'm not quite understanding self.done. When Blue defeats all Red cities and units, the game continues to play, rather than declaring victory. main guard wod_server.py contains quite a lot of code, and that is fine. But we don't have the traditional if __name__ == "__main__": guard, so automated unit tests cannot safely import this module without undesired side effects, and that is a problem. contract The wod_client interp() function really needs a docstring. What promises is it making to the caller? And marching_squares() needs either a docstring or at least some comments, perhaps a literature citation. Also, in handle_events() and in draw() we need to break out some helpers. "values" TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } Yes, each of those four is a numeric "value". But tell us what those numbers mean. Do they relate to vision? To mobility? COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] Consider defining some Enums or some MANIFEST_CONSTANTS, and then put named colors into that list. gameplay grid markings Distance between units plays an important role in this game. Consider superimposing a purely cosmetic square or hex grid upon the display, to help folks visually judge relative distances. Paper maps often have a "12 miles to the inch" type of legend in the corner, with some black & white markings that let you put your thumb first here, then there, and say it's so many miles to destination. If there's no visible grid, consider putting some standard size markings in a corner legend. within range I have some idea of which units are interacting (killing) which other units. But it's not completely obvious, and I was surprised at how many special cases the server code handles. I feel like there's enough information available to the client module that it could choose to render orange lighting zaps coming from an attacking unit, that sort of thing. To support this, we would need a common library module which both server and client import, describing the damage model. There's two things I care about for a unit: health (damage), and rate of damage (or rate of healing) With the current game display, I have a hard time knowing which units in the fray are actually interacting, and if I have pulled a damaged unit "far enough" away from the melee. There is room to reveal derivative of health, IDK maybe with a simple left/right arrow to show it is dwindling or increasing. random damage After Blue defeats all Red cities and troops, I still observe Blue deaths, and respawns at cities. I cannot explain the damage / death events. update speed We already track FPS. I wouldn't mind seeing a one-line speed report that appears once per minute. It could mention how much usermode CPU we've been using recently. I would love to know about episodes where we asked for an unusually small sleep() time. There's no automated unittest for a server update cycle, and that makes it hard to profile the server code to see where the hot spots are. A test could include a scaling parameter, so we compare elapsed time on small vs. large maps or army sizes. It is often the case that "most" troops lack an active path. During profiling I would want to know if a "keep the status quo" approach is a good fit for such idle troops, so they're updated less often. The marching squares (visibility) calculations could perhaps be scheduled less often than troop updates are, without being visually jarring. Every nth frame might suffice. summary score Maybe display total number of per-player troops? (Or does that only become of interest once a player loses all cities?) Total number of "deployed" (out-of-city) troops? Total deaths? Maybe display the current aggregate damage (or "heal") rate for each player? Maybe a mouse-over on a troop would reveal its damage rate? Could be a tooltip near the pointer, but probably better to bury it in a corner display near the legend, so it's less distracting. Share Follow edited Feb 7 at 16:05 answered Feb 6 at 21:36 J_H's user avatar J_H 46k33 gold badges4141 silver badges167167 bronze badges is the overhead of converting ndarray to list and back for json worth using ndarrays? (great answer btw +1 and likely +50) – coder CommentedFeb 7 at 0:37 1 "overhead of converting ndarray to list and back for json" Surely we don't do that 45 times per second, right? In any event, nobody cares what one person's opinion might be. It sounds like we want to Extract Helper so the pygame loop can call into the same update() that a unit test or timeit() calls into. And then "worth it" is just a matter of comparing one elapsed time against another. // I happened to be playing Blue. I'm happy to play Red until I witness Blue's extinction, and I imagine I will see a similar "Red troops die / respawn even with no enemy troops alive" effect. – J_H CommentedFeb 7 at 1:35 do what you like with testing its your time/answer || ill look into sending numpy info, it probably will end up worth doing for performance because the brush apply could be improved with numpy for sure, the main performance issue it seems rn 1269497 72.511 0.000 115.930 0.000 wod_server.py:58(apply) – coder CommentedFeb 7 at 1:46 1 Ok, issue 2 shows the relevant screenshot for "unexpected damage". Automated unit tests would help with narrowing this down. – J_H CommentedFeb 7 at 4:26 Add a comment 7 +100 UX When I run the wod_server.py code, I get this prompt: Enter number of players (2-6): That is easy to understand. Then I get this prompt: Enter port to use (0 - 99): But, I am not sure what the numbers mean. The prompt could be a little more specific. It would be better to print a few lines of information before the prompts, giving the user more context. Briefly describe the game to someone who has never played it. You could also offer an option to bypass the introduction when you run the code (for users who have already played it). The code also appears hung for me at this line of output: waiting for players... I don't know how to proceed to actually play the game. Perhaps the code is waiting for someone to run the wod_client.py code. If that is the case, it would be good to explicitly state it. When I run wod_client.py, I get a GUI window, but it is just a black screen, and I don't know what to do. Documentation The PEP 8 style guide recommends adding docstrings for classes and functions. Consider using type hints to describe input and return types of the functions to make the code even more self-documenting. Comments Comments are intended to describe the code, not question it: def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? That comment should be deleted. Simpler I believe this line: (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 can be simplified as: xy[0] ** 2 + xy[1] ** 2 Give it a try. Also, instead of math.sqrt, you can simplify further using hypot: def xy_to_dir_dis(xy): return math.degrees(math.atan2(xy[1], xy[0])), math.hypot(xy[0], xy[1]) hypot can also be used in the elevation_bias function. When I see lines like: self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] I wonder if the code could be simplified by using numpy, which may also have performance benefits. Main guard It is customary to add a "main" guard at the end of the code in file wod_server.py: if __name__ == '__main__': try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() Naming PEP 8 only recommends all caps for constants, not variables. In wod_server.py, PLAYERS would be players. Tools You could run code development tools to automatically find some style issues with your code. ruff identifies things like this in wod_server.py: E714 [*] Test for object identity should be `is not` | | self.city_border_brush.apply(player.border, city.position, 1.0) | for other_player in self.players: | if not player is other_player: | ^^^^^^^^^^^^^^^^^^^^^^ E714 | for city in self.cities: | if city.owner is other_player: | = help: Convert to `is not` Share Follow answered Feb 3 at 12:50 toolic's user avatar toolic 21.9k66 gold badges3232 silver badges270270 bronze badges Add a comment 5 Context managers Both your Server and Client classes in simple_socket.py have close functions. I can't help but think that maybe these classes should implement the behaviors of a context manager so you don't necessarily have to explicitly manage this. Boolean comparisons Don't do this. self.done == True Simpler: self.done Early exits In simple_socket.py you have a function: def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" I can't help but think this reads better with an early return in the event msg_length is false. def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if not msg_length: return "" msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) You also never use msg_length except after converting it to an int so rather than repeating yourself: def rcv(self): msg_length = int(self.client.recv(HEADER).decode(FORMAT)) total_received = 0 if not msg_length: return "" msg = [] while total_received < msg_length: data = self.client.recv(msg_length) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) DRY In the following I see a lot of repetition in setting self.players. You may wish to create a list of players once and then assign slices of that list to self.players depending on the value of PLAYERS. if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] Share Follow answered Feb 3 at 15:54 Chris's user avatar Chris 18.2k11 gold badge1414 silver badges9292 bronze badges Add a comment 5 I have not attempted to run this program, so these are just some random thoughts after performing a cursory review. Enum First of all, you deal with tuples a lot, so using namedtuple would make sense at some places. Enums are underutilized as well. For example, instead of: COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] I would suggest this approach: from enum import Enum class Color(Enum): RED = 255, 0, 0 GREEN = 0, 255, 0 BLUE = 0, 0, 255 WHITE = 255, 255, 255 BLACK = 0, 0, 0 print(Color.BLUE) Then you can use expressive color names, that make the code immediately more descriptive. In fact, the parentheses are not needed here, you can remove them. I have not bothered to introduce a full RGB class, this setup is sufficient for your purpose. You could also use namedtuple for coordinates. As shown in another question from collections import namedtuple Point = namedtuple('Point', 'x y', defaults=[0, 0]) I regret the lack of comments. Since the code is fairly long, and without being privy to the game, the flow is not easy to follow without testing the program and studying the rules of the game. From time to time, it would be helpful to comment blocks of code, to explain what you are doing at this specific point and why. Naming Function names are not always intuitive. For example, update_cities does not tell me what exactly we are doing here. Other names like xy_to_dir_dis are somewhat cryptic. Don't be afraid to use longer names, there is no bonus for abbreviating things. To take a random example: def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) You are assigning a new variable cs, which takes the value of CELL_SIZE. Just for the sake of abbreviating. That only obfuscates the code. Likewise, self.radius is much more expressive than just r, which could mean anything. And you could explicitly cast self.radius to float in your __init__ method if that matters. But many variable names are very short, which does not facilitate comprehension, and can even be a cause of bugs in my opinion. Class Some classes like Troop, City, Player should be made @dataclass to reduce boilerplate a little bit. In class Environment, the assignment of self.players is quite dense and there is repeat code. I think it would be interesting to explore dataclass and especially the default_factory method. Suggested article which I found helpful: Python Data Classes: A Comprehensive Tutorial Share Follow answered Feb 3 at 17:37 Kate's user avatar Kate 11k1010 silver badges3030 bronze badges Add a comment You must log in to answer this question. Start asking to get answers Find the answer to your question by asking. Explore related questions pythonperformancepygamebattle-simulation See similar questions with these tags. The Overflow Blog Your LLM issues are really data issues Welcome to the “find out” stage of AI Report this ad Report this ad Linked 5 YouTube Downloader implementation using CustomTkinter and Pytubefix 9 Maze Solver in Python inspired by Micro-Mouse Competition Logic 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Related 2 War card game simulator 12 Script for a Civil War game 9 Simple top down shooter game 3 Python 4 Players Snake Game 6 Python/Pygame Fighting Game 5 Simple Python Pygame Game 2 Card game of war using pygame module 5 Very simple Flappy 'Bird' game - First project in Python 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Hot Network Questions Which type of barrel adjuster is missing here? First book of series might leave a bad impression; rewrite or not? What precedent did the 2 Live Crew case actually set? How can we obtain a smoother sphere when cutting it with a plane and moving the cut portion? Non-quasi-separated perfectoid spaces more hot questions Question feed Code Review Tour Help Chat Contact Feedback Company Stack Overflow Stack Internal Stack Data Licensing Stack Ads About Press Legal Privacy Policy Terms of Service Your Privacy Choices Cookie Policy Stack Exchange Network Technology Culture & recreation Life & arts Science Professional Business API Data Blog Facebook Twitter LinkedIn Instagram Site design / logo © 2026 Stack Exchange Inc; user contributions licensed under CC BY-SA . rev 2026.4.23.42490 import json import perlin_noise import math import simple_socket import socket import time import threading import random from constants import * def dir_dis_to_xy(direction, distance): return ( (distance * math.cos(math.radians(direction))), (distance * math.sin(math.radians(direction))), ) def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? return math.degrees(math.atan2(xy[1], xy[0])), math.sqrt( (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 ) class MarchingSquares: def __init__(self): self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] def set_grid(self, new_grid): self.grid = new_grid def get_grid_value(self, x, y): x1, y1 = int(x), int(y) x2, y2 = min(x1 + 1, ROWS), min(y1 + 1, COLS) dx, dy = x - x1, y - y1 p11 = self.grid[x1][y1] p21 = self.grid[x2][y1] p12 = self.grid[x1][y2] p22 = self.grid[x2][y2] val = ( p11 * (1 - dx) * (1 - dy) + p21 * dx * (1 - dy) + p12 * (1 - dx) * dy + p22 * dx * dy ) return val class Brush: def __init__(self, radius=40, strength=1.0, falloff=1.0): self.radius = radius self.strength = strength self.falloff = falloff def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) inv_r = 1.0 / r grid = marching_squares.grid strength = self.strength falloff = self.falloff for j in range(row_start, row_end): px = j * cs dx_sq = (px - mx) ** 2 row = grid[j] for i in range(col_start, col_end): py = i * cs dy = py - my dist_sq = dy * dy + dx_sq if dist_sq <= r * r: dist = math.sqrt(dist_sq) t = dist * inv_r weight = strength + t * (falloff - strength) old = row[i] row[i] = max(0.0, min(1.0, old + (target_value - old) * weight)) class Environment: def __init__(self): self.terrain_speeds = { "water": 0.6, "forest": 0.8, "plains": 1, "hill": 0.7, "mountain": 3, } self.terrain_attacks = { "water": 0.5, "forest": 0.75, "plains": 1, "hill": 1.5, "mountain": 0, } self.terrain_marching = MarchingSquares() self.forest_marching = MarchingSquares() self.cities = [] self.default_vision = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] for y in range(COLS + 1): for x in range(ROWS + 1): self.default_vision[x][y] = 0.0 self.generate_terrain() self.generate_default_vision() # 2 players left and right most cities # 3 players left-bottom, top, right-bottom # 4 players left-bottom, top-left, top-right, right-bottom # 5 players left-bottom, top-left, middle, top-right, right-bottom # 6 players left-bottom, top-left, middle-left, middle-right, top-right, right-bottom left_bottom_city = min(self.cities, key=lambda c: c.position[0] + c.position[1]) top_left_city = min(self.cities, key=lambda c: c.position[0] - c.position[1]) middle_top_city = min( self.cities, key=lambda c: (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5) + c.position[1], ) middle_bottom_city = max( self.cities, key=lambda c: c.position[1] - (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5), ) top_right_city = max(self.cities, key=lambda c: c.position[0] - c.position[1]) right_bottom_city = max( self.cities, key=lambda c: c.position[0] + c.position[1] ) left_city = min(self.cities, key=lambda c: c.position[0]) right_city = max(self.cities, key=lambda c: c.position[0]) top_city = max(self.cities, key=lambda c: c.position[1]) middle_city = min( self.cities, key=lambda c: abs(c.position[0] - (ROWS * CELL_SIZE) / 2) + abs(c.position[1] - (COLS * CELL_SIZE) / 2), ) if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] self.vision_brush = Brush(75, 1, 0) self.city_vision_brush = Brush(175, 1, 0) self.border_brush = Brush(40, 0.05, 0) self.city_border_brush = Brush(80, 0.05, 0) self.players_in_cities = [[] for _ in self.cities] def generate_terrain(self): def elevation_bias(x, y): cx = ROWS / 2 cy = COLS / 2 dx = abs(x - cx) dy = abs(y - cy) dist = math.sqrt((dx) ** 2 + (dy) ** 2) max_dist = math.sqrt((cx) ** 2 + (cy) ** 2) return 1.0 - (dist / max_dist) noise = perlin_noise.PerlinNoise(octaves=3) for y in range(COLS + 1): for x in range(ROWS + 1): value = max( 0, min(1, ((noise([x / 25, y / 25])) - 0.2) + (elevation_bias(x, y))), ) self.terrain_marching.grid[x][y] = value forest_noise = perlin_noise.PerlinNoise(octaves=1.1) for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] value = (min(0.6, forest_noise([x / 30, y / 30])) * 2.0) + 0.3 plains_diff = max(0, (TERRAIN_VALUES["plains"] + 0.1) - terrain_value) hill_diff = max(0, terrain_value - (TERRAIN_VALUES["hill"] - 0.1)) self.forest_marching.grid[x][y] = ( value - (plains_diff * 10) ) - hill_diff * 10 def within_edges(cx, cy): edge_margin = int(1) return ( cx >= edge_margin and cx <= ROWS - edge_margin and cy >= edge_margin and cy <= COLS - edge_margin ) tries = 0 distance = 15 while True: cx = random.randint(0, ROWS) cy = random.randint(0, COLS) terrain_value = self.terrain_marching.grid[cx][cy] if ( ( terrain_value > TERRAIN_VALUES["plains"] and terrain_value < TERRAIN_VALUES["hill"] ) and all( abs(cx * CELL_SIZE - city.position[0]) + abs(cy * CELL_SIZE - city.position[1]) >= CELL_SIZE * distance for city in self.cities ) and within_edges(cx, cy) and self.forest_marching.grid[cx][cy] < THRESHOLD ): px = cx * CELL_SIZE py = cy * CELL_SIZE self.cities.append(City((px, py))) distance = 15 if len(self.cities) >= 10: break tries += 1 if tries >= 100: distance = max(2, distance - 2) tries = 0 def generate_default_vision(self): for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] forest_value = self.forest_marching.grid[x][y] self.default_vision[x][y] = 0.35 + ( max(min((((terrain_value + 0.1) / 1) + 0.2), 1), 0.2) + (0.8 if forest_value > 0.6 else 0.0) ) def draw_info(self, player): ply = self.players[player] vision_grid = ply.vision.grid border_grid = ply.border.grid troops = [] cities = [ ( c.owner.color if c.owner is not None else None, c.position, c.id, c.path, self.players.index(c.owner) if c.owner is not None else -1, ) for c in self.cities ] for troop in [t for p in self.players for t in p.troops]: ply = self.players[player] vision = ply.vision px, py = troop.position gx = px / CELL_SIZE gy = py / CELL_SIZE gx = max(0, min(ROWS, gx)) gy = max(0, min(COLS, gy)) if vision.get_grid_value(gx, gy) < THRESHOLD: troops.append( ( troop.position, troop.owner.color, troop.id, self.players.index(troop.owner), troop.path, troop.health, ) ) return vision_grid, border_grid, troops, cities def get_terrain_info(self): return ( self.terrain_marching.grid, self.forest_marching.grid, [c.position for c in self.cities], ) def get_terrain_name(self, value, fvalue): if fvalue > THRESHOLD: return "forest" for name, v in reversed(TERRAIN_VALUES.items()): if value > v: return name def update_troops(self, paths_to_apply): # split into more functions ? self.players_in_cities = [[] for _ in self.cities] troop_ids = [info[0] for info in paths_to_apply] troop_paths = [info[1] for info in paths_to_apply] for player in self.players: player.vision.grid = [row[:] for row in self.default_vision] for city in self.cities: if city.owner is player: self.city_vision_brush.apply(player.vision, city.position, 0) self.city_border_brush.apply(player.border, city.position, 1.0) for other_player in self.players: if not player is other_player: for city in self.cities: if city.owner is other_player: self.city_border_brush.apply( player.border, city.position, 0.0 ) to_remove = [] for troop in player.troops: if troop.health <= 0: to_remove.append(troop) continue try: tidx = troop_ids.index(id(troop)) troop.path = troop_paths[tidx] except ValueError: pass old_pos = troop.position owned = [city.position for city in self.cities if city.owner is player] if owned: closest_city = min( owned, key=lambda x: xy_to_dir_dis( ((old_pos[0] - x[0]), (old_pos[1] - x[1])) ), ) city_dir, city_dist = xy_to_dir_dis( ((old_pos[0] - closest_city[0]), (old_pos[1] - closest_city[1])) ) sample_points = [ dir_dis_to_xy(city_dir, dist * 20) for dist in range(int(city_dist // 20)) ] border_avg = 0 if sample_points: border_avgs = [] for other_player in self.players: if other_player is not player: border_avgs.append( sum( [ other_player.border.get_grid_value( (closest_city[0] + s_p[0]) / CELL_SIZE, (closest_city[1] + s_p[1]) / CELL_SIZE, ) for s_p in sample_points ] ) / len(sample_points) ) border_avg = sum(border_avgs) / len(border_avgs) dist_penal = max(((city_dist + 250) / 1000), 0.5) healing_power = (1 - (border_avg / 2)) - dist_penal else: healing_power = -0.5 troop.health += healing_power / 25 if troop.health > 100: troop.health = 100 enemies_in_range = [] gx = old_pos[0] / CELL_SIZE gy = old_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) on_terrain = self.get_terrain_name(terrain, forest) if troop.path: target = troop.path[0] terrain_speed = self.terrain_speeds[on_terrain] dir, distance = xy_to_dir_dis( (target[0] - old_pos[0], target[1] - old_pos[1]) ) distance = terrain_speed * 0.1 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) new_pos = (old_pos[0] + new_off_x, old_pos[1] + new_off_y) for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 14: distance = 14 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain dir, distance = xy_to_dir_dis( (target[0] - troop.position[0], target[1] - troop.position[1]) ) if distance < (terrain_speed * 2): troop.path.pop(0) else: new_pos = old_pos for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 15: distance += 0.025 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain if enemies_in_range: attack_power = self.terrain_attacks[on_terrain] / 25 closest = min(enemies_in_range, key=lambda x: x[1]) closest[0].health -= attack_power if on_terrain == "hill": self.city_vision_brush.apply(player.vision, troop.position, 0) else: self.vision_brush.apply(player.vision, troop.position, 0) self.border_brush.apply(player.border, troop.position, 1.0) for i, city in enumerate(self.cities): cx, cy = city.position tx, ty = troop.position dir, dist = xy_to_dir_dis((tx - cx, ty - cy)) if dist < 15: self.players_in_cities[i].append(player) break to_remove.reverse() for t in to_remove: player.troops.remove(t) def update_cities(self, paths_to_apply): city_ids = [info[0] for info in paths_to_apply] city_paths = [info[1] for info in paths_to_apply] for i, city in enumerate(self.cities): try: cidx = city_ids.index(id(city)) city.path = city_paths[cidx] except ValueError: pass cx, cy = city.position last_owner = city.owner if len(self.players_in_cities[i]) == 1: city.owner = self.players_in_cities[i][0] if last_owner is not city.owner: city.timer = 0 city.path = [] if city.owner is not None: city.timer += 1 t_per_c = len(city.owner.troops) / len( [c for c in self.cities if c.owner == city.owner] ) if city.timer >= 45 * (30 * t_per_c) and t_per_c < 10: city.owner.troops.append( Troop( ( cx + random.randrange(-6, 6), cy + random.randrange(-6, 6), ), city.owner, city.path.copy(), ) ) city.timer = 0 class Troop: def __init__(self, position, owner, path=None): self.position = position self.health = 100 self.path = path if path is not None else [] self.owner = owner self.id = id(self) class City: def __init__(self, position): self.position = position self.timer = 0 self.owner = None self.id = id(self) self.path = [] class Player: def __init__(self, start_pos, color, environment): self.start_pos = start_pos self.color = color self.troops = [Troop(self.start_pos, self)] self.border = MarchingSquares() self.vision = MarchingSquares() self.vision.grid = [row[:] for row in environment.default_vision] class Game: def __init__(self): self.FPS = 45 self.last_time = time.perf_counter() self.frame_time = 1 / self.FPS self.done = False self.server = simple_socket.Server( socket.gethostbyname(str(socket.gethostname())), 1200 ) self.environment = Environment() self.player_inputs = [[] for i in range(PLAYERS)] self.player_city_inputs = [[] for i in range(PLAYERS)] self.player_pause_requests = [False for i in range(PLAYERS)] self.started = False def run_game(self): self.ready = True try: port = int(input("Enter port to use (0 - 99): ")) self.server.port = PORTS[max(0, min(99, port))] except ValueError: pass print("ip: ", self.server.ip, ", port: ", self.server.port) print("starting server...") self.server.start() print("waiting for players...") self.server.lsn(conns=PLAYERS) for player_num in range(PLAYERS): conn, addr = self.server.accept() player_thread = threading.Thread( target=self.handle_player, args=(player_num, conn, addr) ) player_thread.start() print("player: ", player_num, " connected") print("All players connected, starting game!") self.started = True while not self.done: if not all(self.player_pause_requests): self.game_logic() current_time = time.perf_counter() delta_time = current_time - self.last_time self.last_time = current_time if delta_time < self.frame_time: time.sleep(self.frame_time - delta_time) # elif delta_time < self.frame_time*0.75: # self.dots = len(self.environment.players[0].troops) # print(self.dots) def handle_player(self, player_number, conn, addr): self.server.send( [conn], json.dumps( ( *self.environment.get_terrain_info(), player_number, ), separators=(",", ":"), ), ) while not self.started: time.sleep(0.1) draw_info = json.dumps([[], [], [], []], separators=(",", ":")) while True: if self.ready: draw_info = json.dumps( self.environment.draw_info(player_number), separators=(",", ":") ) self.server.send([conn], draw_info) else: self.server.send([conn], draw_info) player_in = json.loads(self.server.rcv(conn)) if player_in == "close" or self.done == True: self.done = True self.server.close(conn) print("player: ", player_number, " left") break if player_in: if player_in == "pause": self.player_pause_requests[player_number] = True elif player_in == "unpause": self.player_pause_requests[player_number] = False else: self.player_inputs[player_number].extend(player_in[0]) self.player_city_inputs[player_number].extend(player_in[1]) def game_logic(self): city_paths_to_apply = [] for p_num in range(PLAYERS): if self.player_city_inputs[p_num]: city_paths_to_apply.extend(self.player_city_inputs[p_num]) self.player_city_inputs = [[] for i in range(PLAYERS)] self.environment.update_cities(city_paths_to_apply) paths_to_apply = [] for p_num in range(PLAYERS): if self.player_inputs[p_num]: paths_to_apply.extend(self.player_inputs[p_num]) self.player_inputs = [[] for i in range(PLAYERS)] self.ready = False self.environment.update_troops(paths_to_apply) self.ready = True try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() wod_client.py: import simple_socket import pygame import json from constants import * def interp(threshold, a, b): if a == b: return 0.5 t = (threshold - a) / (b - a) return max(0.0, min(1.0, t)) def marching_squares(grid, cell_size, rows, cols, threshold): segments = [] cs = cell_size for j in range(rows): for i in range(cols): c0 = grid[j][i] c3 = grid[j][i + 1] c2 = grid[j + 1][i + 1] c1 = grid[j + 1][i] p_top = interp(threshold, c0, c1) p_right = interp(threshold, c1, c2) p_bottom = interp(threshold, c3, c2) p_left = interp(threshold, c0, c3) x = j * cs y = i * cs p0 = (x + p_top * cs, y) p1 = (x + cs, y + p_right * cs) p2 = (x + p_bottom * cs, y + cs) p3 = (x, y + p_left * cs) idx = 0 if c0 > threshold: idx |= 1 if c1 > threshold: idx |= 2 if c2 > threshold: idx |= 4 if c3 > threshold: idx |= 8 if idx == 0 or idx == 15: pass elif idx == 1: segments.append((p3, p0)) elif idx == 2: segments.append((p0, p1)) elif idx == 3: segments.append((p3, p1)) elif idx == 4: segments.append((p1, p2)) elif idx == 5: segments.append((p3, p0)) segments.append((p1, p2)) elif idx == 6: segments.append((p0, p2)) elif idx == 7: segments.append((p3, p2)) elif idx == 8: segments.append((p2, p3)) elif idx == 9: segments.append((p0, p2)) elif idx == 10: segments.append((p0, p1)) segments.append((p2, p3)) elif idx == 11: segments.append((p1, p2)) elif idx == 12: segments.append((p1, p3)) elif idx == 13: segments.append((p0, p1)) elif idx == 14: segments.append((p3, p0)) return segments def marching_squares_poly(grid, cell_size, rows, cols, threshold): polys = [] cs = cell_size thr = threshold for i in range(rows): for j in range(cols): c0 = grid[i][j] c1 = grid[i][j + 1] c2 = grid[i + 1][j + 1] c3 = grid[i + 1][j] row_pos = i * cs col_pos = j * cs v0 = (row_pos, col_pos) v1 = (row_pos, col_pos + cs) v2 = (row_pos + cs, col_pos + cs) v3 = (row_pos + cs, col_pos) p_top = (row_pos, col_pos + interp(threshold, c0, c1) * cs) p_right = (row_pos + interp(threshold, c1, c2) * cs, col_pos + cs) p_bottom = (row_pos + cs, col_pos + interp(threshold, c3, c2) * cs) p_left = (row_pos + interp(threshold, c0, c3) * cs, col_pos) inside = [c0 > thr, c1 > thr, c2 > thr, c3 > thr] idx = 0 if inside[0]: idx |= 1 if inside[1]: idx |= 2 if inside[2]: idx |= 4 if inside[3]: idx |= 8 if idx == 0: continue if idx == 15: polys.append([v0, v1, v2, v3]) continue pts = { "v0": v0, "v1": v1, "v2": v2, "v3": v3, "p_top": p_top, "p_right": p_right, "p_bottom": p_bottom, "p_left": p_left, } specs = TABLE.get(idx, []) for spec in specs: poly = [pts[name] for name in spec] compact = [] for p in poly: if not compact or (abs(p[0] - compact[-1][0]) > 1e-9 or abs(p[1] - compact[-1][1]) > 1e-9): compact.append(p) if len(compact) >= 3: polys.append(compact) return polys def marching_squares_layers(grid, cell_size, rows, cols, thresholds): layers = [] for thr in thresholds: threshold = thr polys = marching_squares_poly(grid, cell_size, rows, cols, threshold) layers.append(polys) return layers class Game: def __init__(self, title): pygame.init() info_object = pygame.display.Info() desktop_width = info_object.current_w desktop_height = info_object.current_h self.size = (desktop_width - 20, desktop_height - 100) self.factor = min(self.size[0] / WORLD_X, self.size[1] / WORLD_Y) self.screen = pygame.display.set_mode(self.size) pygame.display.set_caption(title) pygame.event.set_allowed( [ pygame.KEYDOWN, pygame.QUIT, pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION, pygame.MOUSEWHEEL, ] ) self.clock = pygame.time.Clock() self.done = False self.zoom_levels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3.5, 4, 6] self.zoom_idx = self.zoom_levels.index(1) self.zoom = self.get_zoom(self.zoom_idx) self.camx, self.camy = 0.0, 0.0 self.panning = False self.pan_start_mouse = (0, 0) self.pan_start_cam = (0.0, 0.0) self.draw_info = None self.player_input = [[], []] self.paths = [] self.drawing_path = False self.city_paths = [] self.drawing_city_path = False self.pause = False self.terrain_by_zoom = {} def run_game(self): ip, port = input("ip\n: "), input("\nport\n: ") print("connecting...") self.client = simple_socket.Client(ip, PORTS[min(99, max(0, int(port)))]) self.client.connect() print("connection successful!") print("drawing terrain...") terrain_grid, forrest_grid, cities, self.player_num = json.loads(self.client.rcv()) self.color = COLORS[self.player_num] layers = marching_squares_layers(terrain_grid, CELL_SIZE, ROWS, COLS, list(TERRAIN_VALUES.values())) layers.append(marching_squares_poly(forrest_grid, CELL_SIZE, ROWS, COLS, THRESHOLD)) for i in range(len(self.zoom_levels)): z = self.get_zoom(i) sw = max(1, int(WORLD_X * z)) sh = max(1, int(WORLD_Y * z)) surf = pygame.Surface((sw, sh), pygame.SRCALPHA) for poly in layers[0]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (0, 220, 255), scaled, 0) for poly in layers[1]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (20, 180, 20), scaled, 0) for poly in layers[2]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (150, 150, 150), scaled, 0) for poly in layers[3]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (100, 100, 100), scaled, 0) for poly in layers[4]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (30, 125, 30), scaled, 0) for position in cities: if position is None: continue cx, cy = int(position[0] * z), int(position[1] * z) pygame.draw.circle(surf, (255, 215, 0), (cx, cy), max(1, int(15 * z))) self.terrain_by_zoom[z] = surf print("terrain drawn! starting game (waiting for other players)...") self.draw_info = json.loads(self.client.rcv()) while not self.done: self.handle_events() self.draw() pygame.display.flip() self.clock.tick(30) self.client.close() pygame.quit() def handle_events(self): if not self.pause: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.MOUSEBUTTONDOWN: if e.button == 3 and not self.drawing_path and not self.drawing_city_path: self.panning = True self.pan_start_mouse = e.pos self.pan_start_cam = (self.camx, self.camy) elif e.button == 4 and not self.drawing_path and not self.drawing_city_path: self.zoom_in_at(e.pos) elif e.button == 5 and not self.drawing_path and not self.drawing_city_path: self.zoom_out_at(e.pos) elif e.button == 1: mx, my = e.pos[0], e.pos[1] troops = self.draw_info[2] r = max(1, int(7 * self.zoom)) r3 = r * 3 best = None best_dist2 = None best_pos = None for pos, color, tid, owner, path, health in troops: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best is None or d2 < best_dist2: best_pos = pos best = tid best_dist2 = d2 if best is not None: self.drawing_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.paths): if id_path[0] == best: to_pop = i if to_pop is not None: self.paths.pop(to_pop) self.paths.append((best, [best_pos])) else: mx, my = e.pos[0], e.pos[1] cities = self.draw_info[3] r = max(1, int(7 * self.zoom)) r3 = r * 3 best_city = None best_dist2 = None best_pos = None for color, pos, cid, path, owner in cities: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best_city is None or d2 < best_dist2: best_pos = pos best_city = cid best_dist2 = d2 if best_city is not None: self.drawing_city_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.city_paths): if id_path[0] == best_city: to_pop = i if to_pop is not None: self.city_paths.pop(to_pop) self.city_paths.append((best_city, [best_pos])) elif e.type == pygame.MOUSEBUTTONUP: if e.button == 3: self.panning = False if e.button == 1: self.drawing_path = False self.drawing_city_path = False elif e.type == pygame.MOUSEMOTION: if self.drawing_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.paths[-1][1].append((wx, wy)) elif self.drawing_city_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.city_paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.city_paths[-1][1].append((wx, wy)) elif self.panning: mx, my = e.pos sx, sy = self.pan_start_mouse dx = (mx) - sx dy = (my) - sy self.camx = self.pan_start_cam[0] - dx / self.zoom self.camy = self.pan_start_cam[1] - dy / self.zoom self.clamp_camera() elif e.type == pygame.MOUSEWHEEL: if not self.drawing_path and not self.drawing_city_path: mx, my = pygame.mouse.get_pos() if e.y > 0: self.zoom_in_at((mx, my)) elif e.y < 0: self.zoom_out_at((mx, my)) elif e.type == pygame.KEYDOWN: if e.key == pygame.K_c: self.paths = [] self.city_paths = [] elif e.key == pygame.K_SPACE: if (not self.drawing_path and not self.drawing_city_path) and (self.paths or self.city_paths): for id, path in self.paths: path.pop(0) for id, path in self.city_paths: path.pop(0) self.player_input[0] = self.paths self.player_input[1] = self.city_paths self.paths = [] self.city_paths = [] elif e.key == pygame.K_p: self.player_input = "pause" self.pause = True else: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.KEYDOWN: if e.key == pygame.K_p: self.player_input = "unpause" self.pause = False self.client.send(json.dumps(self.player_input, separators=(",", ":"))) self.player_input = [[], []] def zoom_in_at(self, screen_pos): if self.zoom_idx < len(self.zoom_levels) - 1: self.set_zoom_index(self.zoom_idx + 1, screen_pos) def zoom_out_at(self, screen_pos): if self.zoom_idx > 0: self.set_zoom_index(self.zoom_idx - 1, screen_pos) def get_zoom(self, zoom_idx): return self.zoom_levels[zoom_idx] * self.factor def set_zoom_index(self, new_idx, screen_pos): old_zoom = self.zoom new_zoom = self.get_zoom(new_idx) sx, sy = screen_pos world_x = self.camx + sx / old_zoom world_y = self.camy + sy / old_zoom self.zoom_idx = new_idx self.zoom = new_zoom self.camx = world_x - sx / new_zoom self.camy = world_y - sy / new_zoom self.clamp_camera() def clamp_camera(self): max_camx = max(0.0, WORLD_X - (self.size[0] / self.zoom)) max_camy = max(0.0, WORLD_Y - (self.size[1] / self.zoom)) if self.camx < 0.0: self.camx = 0.0 if self.camy < 0.0: self.camy = 0.0 if self.camx > max_camx: self.camx = max_camx if self.camy > max_camy: self.camy = max_camy def draw(self): self.screen.fill((255, 255, 255)) vision_grid, border_grid, troops, cities = self.draw_info = json.loads( self.client.rcv() ) z = self.zoom terrain_surf = self.terrain_by_zoom[z] offset_x = int(-self.camx * z) offset_y = int(-self.camy * z) self.screen.blit(terrain_surf, (offset_x, offset_y)) dyn_w = max(1, int(WORLD_X * z)) dyn_h = max(1, int(WORLD_Y * z)) dynamic = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) fog = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) paths_to_draw = [] for color, position, cid, path, owner in cities: if path and owner == self.player_num: path.insert(0, position) paths_to_draw.append(path) if color is not None: px = int(position[0] * z) py = int(position[1] * z) pole_bottom = (px, py) pole_top = (px, int(py - 30 * z)) pygame.draw.line(dynamic, (80, 80, 80), pole_bottom, pole_top, max(1, int(3 * z))) flag_color = tuple(color) if isinstance(color, (list, tuple)) else color fw, fh = int(20 * z), int(14 * z) p1 = (pole_top[0], pole_top[1]) p2 = (pole_top[0] + fw, pole_top[1] + fh // 2) p3 = (pole_top[0], pole_top[1] + fh) pygame.draw.polygon(dynamic, flag_color, [p1, p2, p3]) pygame.draw.polygon(dynamic, (0, 0, 0), [p1, p2, p3], max(1, int(1 * z))) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (240, 180, 0), (px, py), (px2, py2), max(1, int(4 * z))) paths_to_draw = [] tids = [tid for tid, path in self.paths] for pos, color, tid, owner, path, health in troops: px = int(pos[0] * z) py = int(pos[1] * z) r = max(1, int(7 * z)) rgb = color if tid in tids: factor = 0.5 rgb = [max(0, min(255, int(x * factor))) for x in color] if path and owner == self.player_num: path.insert(0, pos) paths_to_draw.append(path) pygame.draw.rect(dynamic, (0, 255, 0), pygame.rect.Rect(px-r, (py-r)-max(1, int(3 * z)), (r*2)*(health/100), max(1, int(3 * z)))) pygame.draw.circle(dynamic, rgb, (px, py), r) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, self.color, (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.city_paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for a, b in marching_squares(border_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): ax = int(a[0] * z) ay = int(a[1] * z) bx = int(b[0] * z) by = int(b[1] * z) pygame.draw.line(fog, (0, 0, 0), (ax, ay), (bx, by), max(1, int(3 * z))) for poly in marching_squares_poly(vision_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(fog, (0, 0, 0, 150), scaled, 0) if self.pause: font = pygame.font.SysFont(None, 48) text_surface = font.render("Pause", False, (0, 0, 0)) fog.blit(text_surface, (10, 10)) self.screen.blit(dynamic, (offset_x, offset_y)) self.screen.blit(fog, (offset_x, offset_y)) game_play = Game("WAR OF DOTS") game_play.run_game() simple_socket.py: import socket ####### https://docs.python.org/3/library/socket.html#socket.socket.sendfile ####### #socket.gethostbyname(str(socket.gethostname()))# HEADER, FORMAT = 64, "utf-8" class Client: def __init__(self, servip, port): self.servip = servip self.port = port def connect(self): self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) ADDR = (self.servip, self.port) self.client.connect(ADDR) def send(self, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) self.client.sendall(send_length) self.client.sendall(message) def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self): self.client.close() class Server: def __init__(self, ip, port): self.ip = ip self.port = port def start(self): self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) ADDR = (self.ip, self.port) self.server.bind(ADDR) self.conns = [] def lsn(self, conns=0): if conns > 0: self.server.listen(conns) else: self.server.listen() def accept(self): conn, addr = self.server.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.conns.append(conn) return conn, addr def send(self, conns, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) for conn in conns: conn.sendall(send_length) conn.sendall(message) def rcv(self, conn): msg_length = conn.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = conn.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self, conn): conn.close() self.conns.remove(conn) constants.py: CELL_SIZE = 20 SIZE = (1280, 700) WORLD_X, WORLD_Y = SIZE ROWS = int(SIZE[0]//CELL_SIZE) COLS = int(SIZE[1]//CELL_SIZE) TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } TROOP_R = 7 THRESHOLD = 0.5 PLAYERS = 2 COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] TABLE = { 1: [["v0", "p_top", "p_left"]], 2: [["v1", "p_right", "p_top"]], 3: [["p_left", "v0", "v1", "p_right"]], 4: [["v2", "p_bottom", "p_right"]], 5: [["v0", "p_top", "p_left"], ["v2", "p_right", "p_bottom"]], 6: [["p_top", "v1", "v2", "p_bottom"]], 7: [["p_left", "v0", "v1", "v2", "p_bottom"]], 8: [["v3", "p_left", "p_bottom"]], 9: [["p_top", "v0", "v3", "p_bottom"]], 10: [["p_top", "v1", "p_right"], ["p_bottom", "v3", "p_left"]], 11: [["v0", "v1", "p_right", "p_bottom", "v3"]], 12: [["p_right", "v2", "v3", "p_left"]], 13: [["v0", "p_top", "p_right", "v2", "v3"]], 14: [["v1", "v2", "v3", "p_left", "p_top"]], } PORTS = [i for i in range(1200, 1300)] Question: Ultimately I am looking for performance because I want to increase map size for more players and not lag the server or clients. Any other improvements and bug fixes (I hope there are none!) are welcome. Gameplay suggestions are especially welcome if you actually play it! EDIT: Also I have found a few bugs; the zoom controls are bugged and the bottom left and top right city selections were wrong so ignore them for now pls. oh! and the brush apply function doesn't go to the bottom and right edges. Bounties: I will be placing bounties, to reward the efforts people go to to review this code beyond the accepted answer. pythonperformancepygamebattle-simulation Share Follow edited Feb 18 at 0:41 asked Feb 3 at 10:40 coder's user avatar coder 31911 silver badge2424 bronze badges 2 You really made me watch trailer/videos about war of dots and you image seems quite like the original. I'd hope to find time for this question, it really looks fun. – Thingamabobs CommentedFeb 4 at 4:16 1 Not exactly the feedback you were looking for, but this very much looks like a golf simulator. We see the holes (only 10 of them, so I guess a short course), the green, the rough, the water traps, the locations of the balls people have been hitting, and I guess a dirt trap? I can't unsee it. Best of luck on the game. – Seth Robertson CommentedFeb 5 at 0:18 @SethRobertson haha thanks, i love how it turned out visually and hope it clearly displays the game state. – coder CommentedFeb 5 at 1:07 Add a comment 4 Answers Sorted by: Highest score (default) 3 +50 Wow, what a great game! Thank you. I still need to diagnose why pressing "P" to pause causes fatal stacktrace. initial rendezvous Using input() is kind of OK. But you should definitely let me specify "127.0.0.1" and port "0" in sys.argv, with perhaps the host defaulting to localhost or to the value of an optional env var. The whole business of discussing "port 0" with the user, and then biasing it by 1200 during the bind() call, is needlessly confusing. Just tell the user that ports like 1200 and 1201 will be used. Better yet, make it automatic. The user specifies a hostname, on which server is already running, and the client makes a connection and prints out how many users are already on that server. If zero existing players, then make me 0, and so on. I was a little surprised that all players can connect using same port number, or using distinct port numbers, and in both situations the game works fine. It feels like we're exposing more complexity than strictly necessary. diagnostic Personally, I found getting a ConnectionRefusedError very informative. But you might want a try / except which replaces that with some advice about needing to run the server before running any clients. import PEP-8 asks for three sections of imports, each alphabetically sorted. import json import perlin_noise import math import simple_socket Use isort to accomplish that. Why does it matter? I wound up doing uv add simple_socket and pulling in simple-socket 0.0.10 from pypi, before I eventually noticed your simple_socket.py module. Grouping those libraries tells the Reader where the library came from, and where to go looking for the docs. In your repo you should definitely add a Makefile or similar bash script that shows how you produce a .exe when cutting a release. Consider publishing binaries on a different site, or at least in a different repo, as git really wants to diff text edits in order to avoid bloat. Binaries are incompressible, so a lot of git history can accumulate in short order. You should publish a pyproject.toml file that shows how collaborators should install dependencies, and how you publish to pypi. wildcard from constants import * Uggh! Please don't. Remove the star, note the missing symbols, and use your IDE's auto-complete to fill them in explicitly. As a Reader, when I see star I think "all bets are off", because if I don't recognize an unfamiliar symbol I won't know if it should have matched the wildcard. Even worse when more than one import is wildcarded. Also, I can't grep to see where something came from. tuple unpack In xy_to_dir_dis() we see atan2(xy[1], xy[0]). Please refrain from using those cryptic [1], [0] subscripts which are unrelated to the problem at hand. Prefer to speak plainly: x, y = xy Then you can use a straightforward atan2(y, x) call. Also, 0 - x is weird, where unary -x would suffice. And why are we negating in the first place, given that the squaring will make it positive? Consider introducing Polar and Cartesian coordinate classes, or borrowing them from an existing pypi library or from the builtin Complex type. Consider keeping the internal representation as radians. pointers self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] Wow, that's a lot of 64-bit pointers to (double prec.) float objects. Prefer array, for a more compact representation with less pointer indirection. If nothing else, smaller footprint is more cache friendly. Better still, use an NDArray, if you're willing to take a dep on NumPy. Consider whether 32-bit single precision floats would be adequate, here. meaningful identifier def get_grid_value(self, x, y): ... val = ( ... This is indeed a getter. But value & val are on the vague side; I would love to know the meaning of the returned number. And I'm not too sure we're properly using the word grid here. It feels more like there's a continuous battle field, occasionally punctuated by grid points, and this interpolation helper is performing get_field_potential_from_grid(). (I imagine that each Red and Blue unit on the field imposes a plus or minus potential, and we're reporting the net potential.) A """docstring""" would have gone a long way toward illuminating Author's Intent for what this helper does. edge case Sorry, but when reading x2, ... = min(x1 + 1, ROWS), ..., I'm not quite sure what to make of it. We already have that x1 is an integer. So either x2 is one bigger, or when at the ROWS limit x2 is identical to x1, so "interpolate" works out to "just return the coarse grid value". And we might be at the limit in one direction but not the other. Seems like it's worth tacking on a sentence at the end of the docstring, or at least add a # comment. The docstring should additionally cite https://en.wikipedia.org/wiki/Marching_squares invariants In Brush.apply, I'm a little surprised that we need to verify radius is positive -- not sure what would shrink it. And we could have just initialized at 40.0, without need for a float() call. enum Helpers like get_terrain_name() make it look like we really want to store names and speed values in an Enum. We could then put such mapping helpers within that class. @dataclass get_terrain_info() looks like it wants to return a TerrainInfo dataclass. helpers As you commented, update_troops() clearly needs to break out several helper functions. The closest_city attribute looks like it could be memoized, since cities don't move. (Not sure why they have a path.) For each player and each discretized grid point, compute and store the closest city. Then a troop can use int() to get its grid point, and look up the closest city without looping. There's a lot of magic numbers in there. Am I worried that they're not given names? No, not really. It does worry me that they don't show up in the player documentation, and as a player it's hard for me to visualize those interactions on-screen. I can't tell if a unit I just moved is now in or out of range for one or another of those special cases. In the Game constructor, 1200 certainly deserves a name with PORT in it. mutable defaults def __init__(self, ..., path=None): ... self.path = path if path is not None else [] Thank you for avoiding a mutable Troop default. The usual idiom here is slightly more compact: self.path = path or [] In the general case we'd need what you wrote, if e.g. path could take on a "falsey" value like 0.0. But here it's only the len() of a list that matters. It seems odd you're caching id(self), since you could cheaply ask for it any time you need it. number of players I propose that there's six players, all the time. But initially they're all inactive. And as TCP clients join, they claim the first inactive player. This makes joining a game after some delay more flexible, and reduces the interactive questions we have to ask up front. a boolean variable is already a boolean ... or self.done == True Prefer a simple ... or self.done. Also, looking at {ready, started, done}, perhaps we have more state variables than needed? For example, the "sleep 100 msec till self.started" loop could perhaps be removed if we deferred creating Players and threads until all had joined. Also, I'm not quite understanding self.done. When Blue defeats all Red cities and units, the game continues to play, rather than declaring victory. main guard wod_server.py contains quite a lot of code, and that is fine. But we don't have the traditional if __name__ == "__main__": guard, so automated unit tests cannot safely import this module without undesired side effects, and that is a problem. contract The wod_client interp() function really needs a docstring. What promises is it making to the caller? And marching_squares() needs either a docstring or at least some comments, perhaps a literature citation. Also, in handle_events() and in draw() we need to break out some helpers. "values" TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } Yes, each of those four is a numeric "value". But tell us what those numbers mean. Do they relate to vision? To mobility? COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] Consider defining some Enums or some MANIFEST_CONSTANTS, and then put named colors into that list. gameplay grid markings Distance between units plays an important role in this game. Consider superimposing a purely cosmetic square or hex grid upon the display, to help folks visually judge relative distances. Paper maps often have a "12 miles to the inch" type of legend in the corner, with some black & white markings that let you put your thumb first here, then there, and say it's so many miles to destination. If there's no visible grid, consider putting some standard size markings in a corner legend. within range I have some idea of which units are interacting (killing) which other units. But it's not completely obvious, and I was surprised at how many special cases the server code handles. I feel like there's enough information available to the client module that it could choose to render orange lighting zaps coming from an attacking unit, that sort of thing. To support this, we would need a common library module which both server and client import, describing the damage model. There's two things I care about for a unit: health (damage), and rate of damage (or rate of healing) With the current game display, I have a hard time knowing which units in the fray are actually interacting, and if I have pulled a damaged unit "far enough" away from the melee. There is room to reveal derivative of health, IDK maybe with a simple left/right arrow to show it is dwindling or increasing. random damage After Blue defeats all Red cities and troops, I still observe Blue deaths, and respawns at cities. I cannot explain the damage / death events. update speed We already track FPS. I wouldn't mind seeing a one-line speed report that appears once per minute. It could mention how much usermode CPU we've been using recently. I would love to know about episodes where we asked for an unusually small sleep() time. There's no automated unittest for a server update cycle, and that makes it hard to profile the server code to see where the hot spots are. A test could include a scaling parameter, so we compare elapsed time on small vs. large maps or army sizes. It is often the case that "most" troops lack an active path. During profiling I would want to know if a "keep the status quo" approach is a good fit for such idle troops, so they're updated less often. The marching squares (visibility) calculations could perhaps be scheduled less often than troop updates are, without being visually jarring. Every nth frame might suffice. summary score Maybe display total number of per-player troops? (Or does that only become of interest once a player loses all cities?) Total number of "deployed" (out-of-city) troops? Total deaths? Maybe display the current aggregate damage (or "heal") rate for each player? Maybe a mouse-over on a troop would reveal its damage rate? Could be a tooltip near the pointer, but probably better to bury it in a corner display near the legend, so it's less distracting. Share Follow edited Feb 7 at 16:05 answered Feb 6 at 21:36 J_H's user avatar J_H 46k33 gold badges4141 silver badges167167 bronze badges is the overhead of converting ndarray to list and back for json worth using ndarrays? (great answer btw +1 and likely +50) – coder CommentedFeb 7 at 0:37 1 "overhead of converting ndarray to list and back for json" Surely we don't do that 45 times per second, right? In any event, nobody cares what one person's opinion might be. It sounds like we want to Extract Helper so the pygame loop can call into the same update() that a unit test or timeit() calls into. And then "worth it" is just a matter of comparing one elapsed time against another. // I happened to be playing Blue. I'm happy to play Red until I witness Blue's extinction, and I imagine I will see a similar "Red troops die / respawn even with no enemy troops alive" effect. – J_H CommentedFeb 7 at 1:35 do what you like with testing its your time/answer || ill look into sending numpy info, it probably will end up worth doing for performance because the brush apply could be improved with numpy for sure, the main performance issue it seems rn 1269497 72.511 0.000 115.930 0.000 wod_server.py:58(apply) – coder CommentedFeb 7 at 1:46 1 Ok, issue 2 shows the relevant screenshot for "unexpected damage". Automated unit tests would help with narrowing this down. – J_H CommentedFeb 7 at 4:26 Add a comment 7 +100 UX When I run the wod_server.py code, I get this prompt: Enter number of players (2-6): That is easy to understand. Then I get this prompt: Enter port to use (0 - 99): But, I am not sure what the numbers mean. The prompt could be a little more specific. It would be better to print a few lines of information before the prompts, giving the user more context. Briefly describe the game to someone who has never played it. You could also offer an option to bypass the introduction when you run the code (for users who have already played it). The code also appears hung for me at this line of output: waiting for players... I don't know how to proceed to actually play the game. Perhaps the code is waiting for someone to run the wod_client.py code. If that is the case, it would be good to explicitly state it. When I run wod_client.py, I get a GUI window, but it is just a black screen, and I don't know what to do. Documentation The PEP 8 style guide recommends adding docstrings for classes and functions. Consider using type hints to describe input and return types of the functions to make the code even more self-documenting. Comments Comments are intended to describe the code, not question it: def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? That comment should be deleted. Simpler I believe this line: (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 can be simplified as: xy[0] ** 2 + xy[1] ** 2 Give it a try. Also, instead of math.sqrt, you can simplify further using hypot: def xy_to_dir_dis(xy): return math.degrees(math.atan2(xy[1], xy[0])), math.hypot(xy[0], xy[1]) hypot can also be used in the elevation_bias function. When I see lines like: self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] I wonder if the code could be simplified by using numpy, which may also have performance benefits. Main guard It is customary to add a "main" guard at the end of the code in file wod_server.py: if __name__ == '__main__': try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() Naming PEP 8 only recommends all caps for constants, not variables. In wod_server.py, PLAYERS would be players. Tools You could run code development tools to automatically find some style issues with your code. ruff identifies things like this in wod_server.py: E714 [*] Test for object identity should be `is not` | | self.city_border_brush.apply(player.border, city.position, 1.0) | for other_player in self.players: | if not player is other_player: | ^^^^^^^^^^^^^^^^^^^^^^ E714 | for city in self.cities: | if city.owner is other_player: | = help: Convert to `is not` Share Follow answered Feb 3 at 12:50 toolic's user avatar toolic 21.9k66 gold badges3232 silver badges270270 bronze badges Add a comment 5 Context managers Both your Server and Client classes in simple_socket.py have close functions. I can't help but think that maybe these classes should implement the behaviors of a context manager so you don't necessarily have to explicitly manage this. Boolean comparisons Don't do this. self.done == True Simpler: self.done Early exits In simple_socket.py you have a function: def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" I can't help but think this reads better with an early return in the event msg_length is false. def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if not msg_length: return "" msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) You also never use msg_length except after converting it to an int so rather than repeating yourself: def rcv(self): msg_length = int(self.client.recv(HEADER).decode(FORMAT)) total_received = 0 if not msg_length: return "" msg = [] while total_received < msg_length: data = self.client.recv(msg_length) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) DRY In the following I see a lot of repetition in setting self.players. You may wish to create a list of players once and then assign slices of that list to self.players depending on the value of PLAYERS. if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] Share Follow answered Feb 3 at 15:54 Chris's user avatar Chris 18.2k11 gold badge1414 silver badges9292 bronze badges Add a comment 5 I have not attempted to run this program, so these are just some random thoughts after performing a cursory review. Enum First of all, you deal with tuples a lot, so using namedtuple would make sense at some places. Enums are underutilized as well. For example, instead of: COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] I would suggest this approach: from enum import Enum class Color(Enum): RED = 255, 0, 0 GREEN = 0, 255, 0 BLUE = 0, 0, 255 WHITE = 255, 255, 255 BLACK = 0, 0, 0 print(Color.BLUE) Then you can use expressive color names, that make the code immediately more descriptive. In fact, the parentheses are not needed here, you can remove them. I have not bothered to introduce a full RGB class, this setup is sufficient for your purpose. You could also use namedtuple for coordinates. As shown in another question from collections import namedtuple Point = namedtuple('Point', 'x y', defaults=[0, 0]) I regret the lack of comments. Since the code is fairly long, and without being privy to the game, the flow is not easy to follow without testing the program and studying the rules of the game. From time to time, it would be helpful to comment blocks of code, to explain what you are doing at this specific point and why. Naming Function names are not always intuitive. For example, update_cities does not tell me what exactly we are doing here. Other names like xy_to_dir_dis are somewhat cryptic. Don't be afraid to use longer names, there is no bonus for abbreviating things. To take a random example: def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) You are assigning a new variable cs, which takes the value of CELL_SIZE. Just for the sake of abbreviating. That only obfuscates the code. Likewise, self.radius is much more expressive than just r, which could mean anything. And you could explicitly cast self.radius to float in your __init__ method if that matters. But many variable names are very short, which does not facilitate comprehension, and can even be a cause of bugs in my opinion. Class Some classes like Troop, City, Player should be made @dataclass to reduce boilerplate a little bit. In class Environment, the assignment of self.players is quite dense and there is repeat code. I think it would be interesting to explore dataclass and especially the default_factory method. Suggested article which I found helpful: Python Data Classes: A Comprehensive Tutorial Share Follow answered Feb 3 at 17:37 Kate's user avatar Kate 11k1010 silver badges3030 bronze badges Add a comment You must log in to answer this question. Start asking to get answers Find the answer to your question by asking. Explore related questions pythonperformancepygamebattle-simulation See similar questions with these tags. The Overflow Blog Your LLM issues are really data issues Welcome to the “find out” stage of AI Report this ad Report this ad Linked 5 YouTube Downloader implementation using CustomTkinter and Pytubefix 9 Maze Solver in Python inspired by Micro-Mouse Competition Logic 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Related 2 War card game simulator 12 Script for a Civil War game 9 Simple top down shooter game 3 Python 4 Players Snake Game 6 Python/Pygame Fighting Game 5 Simple Python Pygame Game 2 Card game of war using pygame module 5 Very simple Flappy 'Bird' game - First project in Python 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Hot Network Questions Which type of barrel adjuster is missing here? First book of series might leave a bad impression; rewrite or not? What precedent did the 2 Live Crew case actually set? How can we obtain a smoother sphere when cutting it with a plane and moving the cut portion? Non-quasi-separated perfectoid spaces more hot questions Question feed Code Review Tour Help Chat Contact Feedback Company Stack Overflow Stack Internal Stack Data Licensing Stack Ads About Press Legal Privacy Policy Terms of Service Your Privacy Choices Cookie Policy Stack Exchange Network Technology Culture & recreation Life & arts Science Professional Business API Data Blog Facebook Twitter LinkedIn Instagram Site design / logo © 2026 Stack Exchange Inc; user contributions licensed under CC BY-SA . rev 2026.4.23.42490 import json import perlin_noise import math import simple_socket import socket import time import threading import random from constants import * def dir_dis_to_xy(direction, distance): return ( (distance * math.cos(math.radians(direction))), (distance * math.sin(math.radians(direction))), ) def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? return math.degrees(math.atan2(xy[1], xy[0])), math.sqrt( (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 ) class MarchingSquares: def __init__(self): self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] def set_grid(self, new_grid): self.grid = new_grid def get_grid_value(self, x, y): x1, y1 = int(x), int(y) x2, y2 = min(x1 + 1, ROWS), min(y1 + 1, COLS) dx, dy = x - x1, y - y1 p11 = self.grid[x1][y1] p21 = self.grid[x2][y1] p12 = self.grid[x1][y2] p22 = self.grid[x2][y2] val = ( p11 * (1 - dx) * (1 - dy) + p21 * dx * (1 - dy) + p12 * (1 - dx) * dy + p22 * dx * dy ) return val class Brush: def __init__(self, radius=40, strength=1.0, falloff=1.0): self.radius = radius self.strength = strength self.falloff = falloff def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) inv_r = 1.0 / r grid = marching_squares.grid strength = self.strength falloff = self.falloff for j in range(row_start, row_end): px = j * cs dx_sq = (px - mx) ** 2 row = grid[j] for i in range(col_start, col_end): py = i * cs dy = py - my dist_sq = dy * dy + dx_sq if dist_sq <= r * r: dist = math.sqrt(dist_sq) t = dist * inv_r weight = strength + t * (falloff - strength) old = row[i] row[i] = max(0.0, min(1.0, old + (target_value - old) * weight)) class Environment: def __init__(self): self.terrain_speeds = { "water": 0.6, "forest": 0.8, "plains": 1, "hill": 0.7, "mountain": 3, } self.terrain_attacks = { "water": 0.5, "forest": 0.75, "plains": 1, "hill": 1.5, "mountain": 0, } self.terrain_marching = MarchingSquares() self.forest_marching = MarchingSquares() self.cities = [] self.default_vision = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] for y in range(COLS + 1): for x in range(ROWS + 1): self.default_vision[x][y] = 0.0 self.generate_terrain() self.generate_default_vision() # 2 players left and right most cities # 3 players left-bottom, top, right-bottom # 4 players left-bottom, top-left, top-right, right-bottom # 5 players left-bottom, top-left, middle, top-right, right-bottom # 6 players left-bottom, top-left, middle-left, middle-right, top-right, right-bottom left_bottom_city = min(self.cities, key=lambda c: c.position[0] + c.position[1]) top_left_city = min(self.cities, key=lambda c: c.position[0] - c.position[1]) middle_top_city = min( self.cities, key=lambda c: (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5) + c.position[1], ) middle_bottom_city = max( self.cities, key=lambda c: c.position[1] - (abs(c.position[0] - (ROWS * CELL_SIZE) / 2) * 1.5), ) top_right_city = max(self.cities, key=lambda c: c.position[0] - c.position[1]) right_bottom_city = max( self.cities, key=lambda c: c.position[0] + c.position[1] ) left_city = min(self.cities, key=lambda c: c.position[0]) right_city = max(self.cities, key=lambda c: c.position[0]) top_city = max(self.cities, key=lambda c: c.position[1]) middle_city = min( self.cities, key=lambda c: abs(c.position[0] - (ROWS * CELL_SIZE) / 2) + abs(c.position[1] - (COLS * CELL_SIZE) / 2), ) if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] self.vision_brush = Brush(75, 1, 0) self.city_vision_brush = Brush(175, 1, 0) self.border_brush = Brush(40, 0.05, 0) self.city_border_brush = Brush(80, 0.05, 0) self.players_in_cities = [[] for _ in self.cities] def generate_terrain(self): def elevation_bias(x, y): cx = ROWS / 2 cy = COLS / 2 dx = abs(x - cx) dy = abs(y - cy) dist = math.sqrt((dx) ** 2 + (dy) ** 2) max_dist = math.sqrt((cx) ** 2 + (cy) ** 2) return 1.0 - (dist / max_dist) noise = perlin_noise.PerlinNoise(octaves=3) for y in range(COLS + 1): for x in range(ROWS + 1): value = max( 0, min(1, ((noise([x / 25, y / 25])) - 0.2) + (elevation_bias(x, y))), ) self.terrain_marching.grid[x][y] = value forest_noise = perlin_noise.PerlinNoise(octaves=1.1) for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] value = (min(0.6, forest_noise([x / 30, y / 30])) * 2.0) + 0.3 plains_diff = max(0, (TERRAIN_VALUES["plains"] + 0.1) - terrain_value) hill_diff = max(0, terrain_value - (TERRAIN_VALUES["hill"] - 0.1)) self.forest_marching.grid[x][y] = ( value - (plains_diff * 10) ) - hill_diff * 10 def within_edges(cx, cy): edge_margin = int(1) return ( cx >= edge_margin and cx <= ROWS - edge_margin and cy >= edge_margin and cy <= COLS - edge_margin ) tries = 0 distance = 15 while True: cx = random.randint(0, ROWS) cy = random.randint(0, COLS) terrain_value = self.terrain_marching.grid[cx][cy] if ( ( terrain_value > TERRAIN_VALUES["plains"] and terrain_value < TERRAIN_VALUES["hill"] ) and all( abs(cx * CELL_SIZE - city.position[0]) + abs(cy * CELL_SIZE - city.position[1]) >= CELL_SIZE * distance for city in self.cities ) and within_edges(cx, cy) and self.forest_marching.grid[cx][cy] < THRESHOLD ): px = cx * CELL_SIZE py = cy * CELL_SIZE self.cities.append(City((px, py))) distance = 15 if len(self.cities) >= 10: break tries += 1 if tries >= 100: distance = max(2, distance - 2) tries = 0 def generate_default_vision(self): for y in range(COLS + 1): for x in range(ROWS + 1): terrain_value = self.terrain_marching.grid[x][y] forest_value = self.forest_marching.grid[x][y] self.default_vision[x][y] = 0.35 + ( max(min((((terrain_value + 0.1) / 1) + 0.2), 1), 0.2) + (0.8 if forest_value > 0.6 else 0.0) ) def draw_info(self, player): ply = self.players[player] vision_grid = ply.vision.grid border_grid = ply.border.grid troops = [] cities = [ ( c.owner.color if c.owner is not None else None, c.position, c.id, c.path, self.players.index(c.owner) if c.owner is not None else -1, ) for c in self.cities ] for troop in [t for p in self.players for t in p.troops]: ply = self.players[player] vision = ply.vision px, py = troop.position gx = px / CELL_SIZE gy = py / CELL_SIZE gx = max(0, min(ROWS, gx)) gy = max(0, min(COLS, gy)) if vision.get_grid_value(gx, gy) < THRESHOLD: troops.append( ( troop.position, troop.owner.color, troop.id, self.players.index(troop.owner), troop.path, troop.health, ) ) return vision_grid, border_grid, troops, cities def get_terrain_info(self): return ( self.terrain_marching.grid, self.forest_marching.grid, [c.position for c in self.cities], ) def get_terrain_name(self, value, fvalue): if fvalue > THRESHOLD: return "forest" for name, v in reversed(TERRAIN_VALUES.items()): if value > v: return name def update_troops(self, paths_to_apply): # split into more functions ? self.players_in_cities = [[] for _ in self.cities] troop_ids = [info[0] for info in paths_to_apply] troop_paths = [info[1] for info in paths_to_apply] for player in self.players: player.vision.grid = [row[:] for row in self.default_vision] for city in self.cities: if city.owner is player: self.city_vision_brush.apply(player.vision, city.position, 0) self.city_border_brush.apply(player.border, city.position, 1.0) for other_player in self.players: if not player is other_player: for city in self.cities: if city.owner is other_player: self.city_border_brush.apply( player.border, city.position, 0.0 ) to_remove = [] for troop in player.troops: if troop.health <= 0: to_remove.append(troop) continue try: tidx = troop_ids.index(id(troop)) troop.path = troop_paths[tidx] except ValueError: pass old_pos = troop.position owned = [city.position for city in self.cities if city.owner is player] if owned: closest_city = min( owned, key=lambda x: xy_to_dir_dis( ((old_pos[0] - x[0]), (old_pos[1] - x[1])) ), ) city_dir, city_dist = xy_to_dir_dis( ((old_pos[0] - closest_city[0]), (old_pos[1] - closest_city[1])) ) sample_points = [ dir_dis_to_xy(city_dir, dist * 20) for dist in range(int(city_dist // 20)) ] border_avg = 0 if sample_points: border_avgs = [] for other_player in self.players: if other_player is not player: border_avgs.append( sum( [ other_player.border.get_grid_value( (closest_city[0] + s_p[0]) / CELL_SIZE, (closest_city[1] + s_p[1]) / CELL_SIZE, ) for s_p in sample_points ] ) / len(sample_points) ) border_avg = sum(border_avgs) / len(border_avgs) dist_penal = max(((city_dist + 250) / 1000), 0.5) healing_power = (1 - (border_avg / 2)) - dist_penal else: healing_power = -0.5 troop.health += healing_power / 25 if troop.health > 100: troop.health = 100 enemies_in_range = [] gx = old_pos[0] / CELL_SIZE gy = old_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) on_terrain = self.get_terrain_name(terrain, forest) if troop.path: target = troop.path[0] terrain_speed = self.terrain_speeds[on_terrain] dir, distance = xy_to_dir_dis( (target[0] - old_pos[0], target[1] - old_pos[1]) ) distance = terrain_speed * 0.1 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) new_pos = (old_pos[0] + new_off_x, old_pos[1] + new_off_y) for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 14: distance = 14 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain dir, distance = xy_to_dir_dis( (target[0] - troop.position[0], target[1] - troop.position[1]) ) if distance < (terrain_speed * 2): troop.path.pop(0) else: new_pos = old_pos for other_t in player.troops: if other_t == troop: continue other_x, other_y = other_t.position old_off_x, old_off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((old_off_x, old_off_y)) if distance < 15: distance += 0.025 new_off_x, new_off_y = dir_dis_to_xy(dir, distance) change_x, change_y = ( new_off_x - old_off_x, new_off_y - old_off_y, ) new_pos = (new_pos[0] + change_x, new_pos[1] + change_y) gx = new_pos[0] / CELL_SIZE gy = new_pos[1] / CELL_SIZE terrain = self.terrain_marching.get_grid_value(gx, gy) forest = self.forest_marching.get_grid_value(gx, gy) new_terrain = self.get_terrain_name(terrain, forest) hit_enemy = False for other_player in self.players: if not player is other_player: self.border_brush.apply( other_player.border, troop.position, 0.0 ) for other_t in other_player.troops: other_x, other_y = other_t.position off_x, off_y = ( new_pos[0] - other_x, new_pos[1] - other_y, ) dir, distance = xy_to_dir_dis((off_x, off_y)) if distance < 28: hit_enemy = True if distance < 32: enemies_in_range.append((other_t, distance)) out_of_world = ( (new_pos[0] > WORLD_X) or (new_pos[0] < 0) or (new_pos[1] > WORLD_Y) or (new_pos[1] < 0) ) if ( (not new_terrain == "mountain") and not hit_enemy and not out_of_world ): troop.position = new_pos on_terrain = new_terrain if enemies_in_range: attack_power = self.terrain_attacks[on_terrain] / 25 closest = min(enemies_in_range, key=lambda x: x[1]) closest[0].health -= attack_power if on_terrain == "hill": self.city_vision_brush.apply(player.vision, troop.position, 0) else: self.vision_brush.apply(player.vision, troop.position, 0) self.border_brush.apply(player.border, troop.position, 1.0) for i, city in enumerate(self.cities): cx, cy = city.position tx, ty = troop.position dir, dist = xy_to_dir_dis((tx - cx, ty - cy)) if dist < 15: self.players_in_cities[i].append(player) break to_remove.reverse() for t in to_remove: player.troops.remove(t) def update_cities(self, paths_to_apply): city_ids = [info[0] for info in paths_to_apply] city_paths = [info[1] for info in paths_to_apply] for i, city in enumerate(self.cities): try: cidx = city_ids.index(id(city)) city.path = city_paths[cidx] except ValueError: pass cx, cy = city.position last_owner = city.owner if len(self.players_in_cities[i]) == 1: city.owner = self.players_in_cities[i][0] if last_owner is not city.owner: city.timer = 0 city.path = [] if city.owner is not None: city.timer += 1 t_per_c = len(city.owner.troops) / len( [c for c in self.cities if c.owner == city.owner] ) if city.timer >= 45 * (30 * t_per_c) and t_per_c < 10: city.owner.troops.append( Troop( ( cx + random.randrange(-6, 6), cy + random.randrange(-6, 6), ), city.owner, city.path.copy(), ) ) city.timer = 0 class Troop: def __init__(self, position, owner, path=None): self.position = position self.health = 100 self.path = path if path is not None else [] self.owner = owner self.id = id(self) class City: def __init__(self, position): self.position = position self.timer = 0 self.owner = None self.id = id(self) self.path = [] class Player: def __init__(self, start_pos, color, environment): self.start_pos = start_pos self.color = color self.troops = [Troop(self.start_pos, self)] self.border = MarchingSquares() self.vision = MarchingSquares() self.vision.grid = [row[:] for row in environment.default_vision] class Game: def __init__(self): self.FPS = 45 self.last_time = time.perf_counter() self.frame_time = 1 / self.FPS self.done = False self.server = simple_socket.Server( socket.gethostbyname(str(socket.gethostname())), 1200 ) self.environment = Environment() self.player_inputs = [[] for i in range(PLAYERS)] self.player_city_inputs = [[] for i in range(PLAYERS)] self.player_pause_requests = [False for i in range(PLAYERS)] self.started = False def run_game(self): self.ready = True try: port = int(input("Enter port to use (0 - 99): ")) self.server.port = PORTS[max(0, min(99, port))] except ValueError: pass print("ip: ", self.server.ip, ", port: ", self.server.port) print("starting server...") self.server.start() print("waiting for players...") self.server.lsn(conns=PLAYERS) for player_num in range(PLAYERS): conn, addr = self.server.accept() player_thread = threading.Thread( target=self.handle_player, args=(player_num, conn, addr) ) player_thread.start() print("player: ", player_num, " connected") print("All players connected, starting game!") self.started = True while not self.done: if not all(self.player_pause_requests): self.game_logic() current_time = time.perf_counter() delta_time = current_time - self.last_time self.last_time = current_time if delta_time < self.frame_time: time.sleep(self.frame_time - delta_time) # elif delta_time < self.frame_time*0.75: # self.dots = len(self.environment.players[0].troops) # print(self.dots) def handle_player(self, player_number, conn, addr): self.server.send( [conn], json.dumps( ( *self.environment.get_terrain_info(), player_number, ), separators=(",", ":"), ), ) while not self.started: time.sleep(0.1) draw_info = json.dumps([[], [], [], []], separators=(",", ":")) while True: if self.ready: draw_info = json.dumps( self.environment.draw_info(player_number), separators=(",", ":") ) self.server.send([conn], draw_info) else: self.server.send([conn], draw_info) player_in = json.loads(self.server.rcv(conn)) if player_in == "close" or self.done == True: self.done = True self.server.close(conn) print("player: ", player_number, " left") break if player_in: if player_in == "pause": self.player_pause_requests[player_number] = True elif player_in == "unpause": self.player_pause_requests[player_number] = False else: self.player_inputs[player_number].extend(player_in[0]) self.player_city_inputs[player_number].extend(player_in[1]) def game_logic(self): city_paths_to_apply = [] for p_num in range(PLAYERS): if self.player_city_inputs[p_num]: city_paths_to_apply.extend(self.player_city_inputs[p_num]) self.player_city_inputs = [[] for i in range(PLAYERS)] self.environment.update_cities(city_paths_to_apply) paths_to_apply = [] for p_num in range(PLAYERS): if self.player_inputs[p_num]: paths_to_apply.extend(self.player_inputs[p_num]) self.player_inputs = [[] for i in range(PLAYERS)] self.ready = False self.environment.update_troops(paths_to_apply) self.ready = True try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() wod_client.py: import simple_socket import pygame import json from constants import * def interp(threshold, a, b): if a == b: return 0.5 t = (threshold - a) / (b - a) return max(0.0, min(1.0, t)) def marching_squares(grid, cell_size, rows, cols, threshold): segments = [] cs = cell_size for j in range(rows): for i in range(cols): c0 = grid[j][i] c3 = grid[j][i + 1] c2 = grid[j + 1][i + 1] c1 = grid[j + 1][i] p_top = interp(threshold, c0, c1) p_right = interp(threshold, c1, c2) p_bottom = interp(threshold, c3, c2) p_left = interp(threshold, c0, c3) x = j * cs y = i * cs p0 = (x + p_top * cs, y) p1 = (x + cs, y + p_right * cs) p2 = (x + p_bottom * cs, y + cs) p3 = (x, y + p_left * cs) idx = 0 if c0 > threshold: idx |= 1 if c1 > threshold: idx |= 2 if c2 > threshold: idx |= 4 if c3 > threshold: idx |= 8 if idx == 0 or idx == 15: pass elif idx == 1: segments.append((p3, p0)) elif idx == 2: segments.append((p0, p1)) elif idx == 3: segments.append((p3, p1)) elif idx == 4: segments.append((p1, p2)) elif idx == 5: segments.append((p3, p0)) segments.append((p1, p2)) elif idx == 6: segments.append((p0, p2)) elif idx == 7: segments.append((p3, p2)) elif idx == 8: segments.append((p2, p3)) elif idx == 9: segments.append((p0, p2)) elif idx == 10: segments.append((p0, p1)) segments.append((p2, p3)) elif idx == 11: segments.append((p1, p2)) elif idx == 12: segments.append((p1, p3)) elif idx == 13: segments.append((p0, p1)) elif idx == 14: segments.append((p3, p0)) return segments def marching_squares_poly(grid, cell_size, rows, cols, threshold): polys = [] cs = cell_size thr = threshold for i in range(rows): for j in range(cols): c0 = grid[i][j] c1 = grid[i][j + 1] c2 = grid[i + 1][j + 1] c3 = grid[i + 1][j] row_pos = i * cs col_pos = j * cs v0 = (row_pos, col_pos) v1 = (row_pos, col_pos + cs) v2 = (row_pos + cs, col_pos + cs) v3 = (row_pos + cs, col_pos) p_top = (row_pos, col_pos + interp(threshold, c0, c1) * cs) p_right = (row_pos + interp(threshold, c1, c2) * cs, col_pos + cs) p_bottom = (row_pos + cs, col_pos + interp(threshold, c3, c2) * cs) p_left = (row_pos + interp(threshold, c0, c3) * cs, col_pos) inside = [c0 > thr, c1 > thr, c2 > thr, c3 > thr] idx = 0 if inside[0]: idx |= 1 if inside[1]: idx |= 2 if inside[2]: idx |= 4 if inside[3]: idx |= 8 if idx == 0: continue if idx == 15: polys.append([v0, v1, v2, v3]) continue pts = { "v0": v0, "v1": v1, "v2": v2, "v3": v3, "p_top": p_top, "p_right": p_right, "p_bottom": p_bottom, "p_left": p_left, } specs = TABLE.get(idx, []) for spec in specs: poly = [pts[name] for name in spec] compact = [] for p in poly: if not compact or (abs(p[0] - compact[-1][0]) > 1e-9 or abs(p[1] - compact[-1][1]) > 1e-9): compact.append(p) if len(compact) >= 3: polys.append(compact) return polys def marching_squares_layers(grid, cell_size, rows, cols, thresholds): layers = [] for thr in thresholds: threshold = thr polys = marching_squares_poly(grid, cell_size, rows, cols, threshold) layers.append(polys) return layers class Game: def __init__(self, title): pygame.init() info_object = pygame.display.Info() desktop_width = info_object.current_w desktop_height = info_object.current_h self.size = (desktop_width - 20, desktop_height - 100) self.factor = min(self.size[0] / WORLD_X, self.size[1] / WORLD_Y) self.screen = pygame.display.set_mode(self.size) pygame.display.set_caption(title) pygame.event.set_allowed( [ pygame.KEYDOWN, pygame.QUIT, pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP, pygame.MOUSEMOTION, pygame.MOUSEWHEEL, ] ) self.clock = pygame.time.Clock() self.done = False self.zoom_levels = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3.5, 4, 6] self.zoom_idx = self.zoom_levels.index(1) self.zoom = self.get_zoom(self.zoom_idx) self.camx, self.camy = 0.0, 0.0 self.panning = False self.pan_start_mouse = (0, 0) self.pan_start_cam = (0.0, 0.0) self.draw_info = None self.player_input = [[], []] self.paths = [] self.drawing_path = False self.city_paths = [] self.drawing_city_path = False self.pause = False self.terrain_by_zoom = {} def run_game(self): ip, port = input("ip\n: "), input("\nport\n: ") print("connecting...") self.client = simple_socket.Client(ip, PORTS[min(99, max(0, int(port)))]) self.client.connect() print("connection successful!") print("drawing terrain...") terrain_grid, forrest_grid, cities, self.player_num = json.loads(self.client.rcv()) self.color = COLORS[self.player_num] layers = marching_squares_layers(terrain_grid, CELL_SIZE, ROWS, COLS, list(TERRAIN_VALUES.values())) layers.append(marching_squares_poly(forrest_grid, CELL_SIZE, ROWS, COLS, THRESHOLD)) for i in range(len(self.zoom_levels)): z = self.get_zoom(i) sw = max(1, int(WORLD_X * z)) sh = max(1, int(WORLD_Y * z)) surf = pygame.Surface((sw, sh), pygame.SRCALPHA) for poly in layers[0]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (0, 220, 255), scaled, 0) for poly in layers[1]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (20, 180, 20), scaled, 0) for poly in layers[2]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (150, 150, 150), scaled, 0) for poly in layers[3]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (100, 100, 100), scaled, 0) for poly in layers[4]: scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(surf, (30, 125, 30), scaled, 0) for position in cities: if position is None: continue cx, cy = int(position[0] * z), int(position[1] * z) pygame.draw.circle(surf, (255, 215, 0), (cx, cy), max(1, int(15 * z))) self.terrain_by_zoom[z] = surf print("terrain drawn! starting game (waiting for other players)...") self.draw_info = json.loads(self.client.rcv()) while not self.done: self.handle_events() self.draw() pygame.display.flip() self.clock.tick(30) self.client.close() pygame.quit() def handle_events(self): if not self.pause: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.MOUSEBUTTONDOWN: if e.button == 3 and not self.drawing_path and not self.drawing_city_path: self.panning = True self.pan_start_mouse = e.pos self.pan_start_cam = (self.camx, self.camy) elif e.button == 4 and not self.drawing_path and not self.drawing_city_path: self.zoom_in_at(e.pos) elif e.button == 5 and not self.drawing_path and not self.drawing_city_path: self.zoom_out_at(e.pos) elif e.button == 1: mx, my = e.pos[0], e.pos[1] troops = self.draw_info[2] r = max(1, int(7 * self.zoom)) r3 = r * 3 best = None best_dist2 = None best_pos = None for pos, color, tid, owner, path, health in troops: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best is None or d2 < best_dist2: best_pos = pos best = tid best_dist2 = d2 if best is not None: self.drawing_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.paths): if id_path[0] == best: to_pop = i if to_pop is not None: self.paths.pop(to_pop) self.paths.append((best, [best_pos])) else: mx, my = e.pos[0], e.pos[1] cities = self.draw_info[3] r = max(1, int(7 * self.zoom)) r3 = r * 3 best_city = None best_dist2 = None best_pos = None for color, pos, cid, path, owner in cities: if owner == self.player_num: sx = int((pos[0] - self.camx) * self.zoom) sy = int((pos[1] - self.camy) * self.zoom) dx = (mx) - sx dy = (my) - sy d2 = dx * dx + dy * dy if d2 <= r3 * r3: if best_city is None or d2 < best_dist2: best_pos = pos best_city = cid best_dist2 = d2 if best_city is not None: self.drawing_city_path = True wx = self.camx + mx / self.zoom wy = self.camy + my / self.zoom to_pop = None for i, id_path in enumerate(self.city_paths): if id_path[0] == best_city: to_pop = i if to_pop is not None: self.city_paths.pop(to_pop) self.city_paths.append((best_city, [best_pos])) elif e.type == pygame.MOUSEBUTTONUP: if e.button == 3: self.panning = False if e.button == 1: self.drawing_path = False self.drawing_city_path = False elif e.type == pygame.MOUSEMOTION: if self.drawing_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.paths[-1][1].append((wx, wy)) elif self.drawing_city_path: mx, my = e.pos wx = self.camx + (mx) / self.zoom wy = self.camy + (my) / self.zoom lx, ly = self.city_paths[-1][1][-1] dx = wx - lx dy = wy - ly if dx * dx + dy * dy > (14.0 / max(1.0, self.zoom)): self.city_paths[-1][1].append((wx, wy)) elif self.panning: mx, my = e.pos sx, sy = self.pan_start_mouse dx = (mx) - sx dy = (my) - sy self.camx = self.pan_start_cam[0] - dx / self.zoom self.camy = self.pan_start_cam[1] - dy / self.zoom self.clamp_camera() elif e.type == pygame.MOUSEWHEEL: if not self.drawing_path and not self.drawing_city_path: mx, my = pygame.mouse.get_pos() if e.y > 0: self.zoom_in_at((mx, my)) elif e.y < 0: self.zoom_out_at((mx, my)) elif e.type == pygame.KEYDOWN: if e.key == pygame.K_c: self.paths = [] self.city_paths = [] elif e.key == pygame.K_SPACE: if (not self.drawing_path and not self.drawing_city_path) and (self.paths or self.city_paths): for id, path in self.paths: path.pop(0) for id, path in self.city_paths: path.pop(0) self.player_input[0] = self.paths self.player_input[1] = self.city_paths self.paths = [] self.city_paths = [] elif e.key == pygame.K_p: self.player_input = "pause" self.pause = True else: for e in pygame.event.get(): if e.type == pygame.QUIT: self.done = True self.player_input = "close" elif e.type == pygame.KEYDOWN: if e.key == pygame.K_p: self.player_input = "unpause" self.pause = False self.client.send(json.dumps(self.player_input, separators=(",", ":"))) self.player_input = [[], []] def zoom_in_at(self, screen_pos): if self.zoom_idx < len(self.zoom_levels) - 1: self.set_zoom_index(self.zoom_idx + 1, screen_pos) def zoom_out_at(self, screen_pos): if self.zoom_idx > 0: self.set_zoom_index(self.zoom_idx - 1, screen_pos) def get_zoom(self, zoom_idx): return self.zoom_levels[zoom_idx] * self.factor def set_zoom_index(self, new_idx, screen_pos): old_zoom = self.zoom new_zoom = self.get_zoom(new_idx) sx, sy = screen_pos world_x = self.camx + sx / old_zoom world_y = self.camy + sy / old_zoom self.zoom_idx = new_idx self.zoom = new_zoom self.camx = world_x - sx / new_zoom self.camy = world_y - sy / new_zoom self.clamp_camera() def clamp_camera(self): max_camx = max(0.0, WORLD_X - (self.size[0] / self.zoom)) max_camy = max(0.0, WORLD_Y - (self.size[1] / self.zoom)) if self.camx < 0.0: self.camx = 0.0 if self.camy < 0.0: self.camy = 0.0 if self.camx > max_camx: self.camx = max_camx if self.camy > max_camy: self.camy = max_camy def draw(self): self.screen.fill((255, 255, 255)) vision_grid, border_grid, troops, cities = self.draw_info = json.loads( self.client.rcv() ) z = self.zoom terrain_surf = self.terrain_by_zoom[z] offset_x = int(-self.camx * z) offset_y = int(-self.camy * z) self.screen.blit(terrain_surf, (offset_x, offset_y)) dyn_w = max(1, int(WORLD_X * z)) dyn_h = max(1, int(WORLD_Y * z)) dynamic = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) fog = pygame.Surface((dyn_w, dyn_h), pygame.SRCALPHA) paths_to_draw = [] for color, position, cid, path, owner in cities: if path and owner == self.player_num: path.insert(0, position) paths_to_draw.append(path) if color is not None: px = int(position[0] * z) py = int(position[1] * z) pole_bottom = (px, py) pole_top = (px, int(py - 30 * z)) pygame.draw.line(dynamic, (80, 80, 80), pole_bottom, pole_top, max(1, int(3 * z))) flag_color = tuple(color) if isinstance(color, (list, tuple)) else color fw, fh = int(20 * z), int(14 * z) p1 = (pole_top[0], pole_top[1]) p2 = (pole_top[0] + fw, pole_top[1] + fh // 2) p3 = (pole_top[0], pole_top[1] + fh) pygame.draw.polygon(dynamic, flag_color, [p1, p2, p3]) pygame.draw.polygon(dynamic, (0, 0, 0), [p1, p2, p3], max(1, int(1 * z))) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (240, 180, 0), (px, py), (px2, py2), max(1, int(4 * z))) paths_to_draw = [] tids = [tid for tid, path in self.paths] for pos, color, tid, owner, path, health in troops: px = int(pos[0] * z) py = int(pos[1] * z) r = max(1, int(7 * z)) rgb = color if tid in tids: factor = 0.5 rgb = [max(0, min(255, int(x * factor))) for x in color] if path and owner == self.player_num: path.insert(0, pos) paths_to_draw.append(path) pygame.draw.rect(dynamic, (0, 255, 0), pygame.rect.Rect(px-r, (py-r)-max(1, int(3 * z)), (r*2)*(health/100), max(1, int(3 * z)))) pygame.draw.circle(dynamic, rgb, (px, py), r) for path in paths_to_draw: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, self.color, (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for tid, path in self.city_paths: for i, pos in enumerate(path): if not i == (len(path)-1): px = int(pos[0] * z) py = int(pos[1] * z) px2 = int(path[i+1][0] * z) py2 = int(path[i+1][1] * z) pygame.draw.line(dynamic, (0, 0, 0), (px, py), (px2, py2), max(1, int(2 * z))) for a, b in marching_squares(border_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): ax = int(a[0] * z) ay = int(a[1] * z) bx = int(b[0] * z) by = int(b[1] * z) pygame.draw.line(fog, (0, 0, 0), (ax, ay), (bx, by), max(1, int(3 * z))) for poly in marching_squares_poly(vision_grid, CELL_SIZE, ROWS, COLS, THRESHOLD): scaled = [(int(x * z), int(y * z)) for x, y in poly] pygame.draw.polygon(fog, (0, 0, 0, 150), scaled, 0) if self.pause: font = pygame.font.SysFont(None, 48) text_surface = font.render("Pause", False, (0, 0, 0)) fog.blit(text_surface, (10, 10)) self.screen.blit(dynamic, (offset_x, offset_y)) self.screen.blit(fog, (offset_x, offset_y)) game_play = Game("WAR OF DOTS") game_play.run_game() simple_socket.py: import socket ####### https://docs.python.org/3/library/socket.html#socket.socket.sendfile ####### #socket.gethostbyname(str(socket.gethostname()))# HEADER, FORMAT = 64, "utf-8" class Client: def __init__(self, servip, port): self.servip = servip self.port = port def connect(self): self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.client.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) ADDR = (self.servip, self.port) self.client.connect(ADDR) def send(self, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) self.client.sendall(send_length) self.client.sendall(message) def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self): self.client.close() class Server: def __init__(self, ip, port): self.ip = ip self.port = port def start(self): self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) ADDR = (self.ip, self.port) self.server.bind(ADDR) self.conns = [] def lsn(self, conns=0): if conns > 0: self.server.listen(conns) else: self.server.listen() def accept(self): conn, addr = self.server.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.conns.append(conn) return conn, addr def send(self, conns, msg): message = msg.encode(FORMAT) msg_length = len(message) send_length = str(msg_length).encode(FORMAT) send_length += b" " * (HEADER - len(send_length)) for conn in conns: conn.sendall(send_length) conn.sendall(message) def rcv(self, conn): msg_length = conn.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = conn.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" def close(self, conn): conn.close() self.conns.remove(conn) constants.py: CELL_SIZE = 20 SIZE = (1280, 700) WORLD_X, WORLD_Y = SIZE ROWS = int(SIZE[0]//CELL_SIZE) COLS = int(SIZE[1]//CELL_SIZE) TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } TROOP_R = 7 THRESHOLD = 0.5 PLAYERS = 2 COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] TABLE = { 1: [["v0", "p_top", "p_left"]], 2: [["v1", "p_right", "p_top"]], 3: [["p_left", "v0", "v1", "p_right"]], 4: [["v2", "p_bottom", "p_right"]], 5: [["v0", "p_top", "p_left"], ["v2", "p_right", "p_bottom"]], 6: [["p_top", "v1", "v2", "p_bottom"]], 7: [["p_left", "v0", "v1", "v2", "p_bottom"]], 8: [["v3", "p_left", "p_bottom"]], 9: [["p_top", "v0", "v3", "p_bottom"]], 10: [["p_top", "v1", "p_right"], ["p_bottom", "v3", "p_left"]], 11: [["v0", "v1", "p_right", "p_bottom", "v3"]], 12: [["p_right", "v2", "v3", "p_left"]], 13: [["v0", "p_top", "p_right", "v2", "v3"]], 14: [["v1", "v2", "v3", "p_left", "p_top"]], } PORTS = [i for i in range(1200, 1300)] Question: Ultimately I am looking for performance because I want to increase map size for more players and not lag the server or clients. Any other improvements and bug fixes (I hope there are none!) are welcome. Gameplay suggestions are especially welcome if you actually play it! EDIT: Also I have found a few bugs; the zoom controls are bugged and the bottom left and top right city selections were wrong so ignore them for now pls. oh! and the brush apply function doesn't go to the bottom and right edges. Bounties: I will be placing bounties, to reward the efforts people go to to review this code beyond the accepted answer. pythonperformancepygamebattle-simulation Share Follow edited Feb 18 at 0:41 asked Feb 3 at 10:40 coder's user avatar coder 31911 silver badge2424 bronze badges 2 You really made me watch trailer/videos about war of dots and you image seems quite like the original. I'd hope to find time for this question, it really looks fun. – Thingamabobs CommentedFeb 4 at 4:16 1 Not exactly the feedback you were looking for, but this very much looks like a golf simulator. We see the holes (only 10 of them, so I guess a short course), the green, the rough, the water traps, the locations of the balls people have been hitting, and I guess a dirt trap? I can't unsee it. Best of luck on the game. – Seth Robertson CommentedFeb 5 at 0:18 @SethRobertson haha thanks, i love how it turned out visually and hope it clearly displays the game state. – coder CommentedFeb 5 at 1:07 Add a comment 4 Answers Sorted by: Highest score (default) 3 +50 Wow, what a great game! Thank you. I still need to diagnose why pressing "P" to pause causes fatal stacktrace. initial rendezvous Using input() is kind of OK. But you should definitely let me specify "127.0.0.1" and port "0" in sys.argv, with perhaps the host defaulting to localhost or to the value of an optional env var. The whole business of discussing "port 0" with the user, and then biasing it by 1200 during the bind() call, is needlessly confusing. Just tell the user that ports like 1200 and 1201 will be used. Better yet, make it automatic. The user specifies a hostname, on which server is already running, and the client makes a connection and prints out how many users are already on that server. If zero existing players, then make me 0, and so on. I was a little surprised that all players can connect using same port number, or using distinct port numbers, and in both situations the game works fine. It feels like we're exposing more complexity than strictly necessary. diagnostic Personally, I found getting a ConnectionRefusedError very informative. But you might want a try / except which replaces that with some advice about needing to run the server before running any clients. import PEP-8 asks for three sections of imports, each alphabetically sorted. import json import perlin_noise import math import simple_socket Use isort to accomplish that. Why does it matter? I wound up doing uv add simple_socket and pulling in simple-socket 0.0.10 from pypi, before I eventually noticed your simple_socket.py module. Grouping those libraries tells the Reader where the library came from, and where to go looking for the docs. In your repo you should definitely add a Makefile or similar bash script that shows how you produce a .exe when cutting a release. Consider publishing binaries on a different site, or at least in a different repo, as git really wants to diff text edits in order to avoid bloat. Binaries are incompressible, so a lot of git history can accumulate in short order. You should publish a pyproject.toml file that shows how collaborators should install dependencies, and how you publish to pypi. wildcard from constants import * Uggh! Please don't. Remove the star, note the missing symbols, and use your IDE's auto-complete to fill them in explicitly. As a Reader, when I see star I think "all bets are off", because if I don't recognize an unfamiliar symbol I won't know if it should have matched the wildcard. Even worse when more than one import is wildcarded. Also, I can't grep to see where something came from. tuple unpack In xy_to_dir_dis() we see atan2(xy[1], xy[0]). Please refrain from using those cryptic [1], [0] subscripts which are unrelated to the problem at hand. Prefer to speak plainly: x, y = xy Then you can use a straightforward atan2(y, x) call. Also, 0 - x is weird, where unary -x would suffice. And why are we negating in the first place, given that the squaring will make it positive? Consider introducing Polar and Cartesian coordinate classes, or borrowing them from an existing pypi library or from the builtin Complex type. Consider keeping the internal representation as radians. pointers self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] Wow, that's a lot of 64-bit pointers to (double prec.) float objects. Prefer array, for a more compact representation with less pointer indirection. If nothing else, smaller footprint is more cache friendly. Better still, use an NDArray, if you're willing to take a dep on NumPy. Consider whether 32-bit single precision floats would be adequate, here. meaningful identifier def get_grid_value(self, x, y): ... val = ( ... This is indeed a getter. But value & val are on the vague side; I would love to know the meaning of the returned number. And I'm not too sure we're properly using the word grid here. It feels more like there's a continuous battle field, occasionally punctuated by grid points, and this interpolation helper is performing get_field_potential_from_grid(). (I imagine that each Red and Blue unit on the field imposes a plus or minus potential, and we're reporting the net potential.) A """docstring""" would have gone a long way toward illuminating Author's Intent for what this helper does. edge case Sorry, but when reading x2, ... = min(x1 + 1, ROWS), ..., I'm not quite sure what to make of it. We already have that x1 is an integer. So either x2 is one bigger, or when at the ROWS limit x2 is identical to x1, so "interpolate" works out to "just return the coarse grid value". And we might be at the limit in one direction but not the other. Seems like it's worth tacking on a sentence at the end of the docstring, or at least add a # comment. The docstring should additionally cite https://en.wikipedia.org/wiki/Marching_squares invariants In Brush.apply, I'm a little surprised that we need to verify radius is positive -- not sure what would shrink it. And we could have just initialized at 40.0, without need for a float() call. enum Helpers like get_terrain_name() make it look like we really want to store names and speed values in an Enum. We could then put such mapping helpers within that class. @dataclass get_terrain_info() looks like it wants to return a TerrainInfo dataclass. helpers As you commented, update_troops() clearly needs to break out several helper functions. The closest_city attribute looks like it could be memoized, since cities don't move. (Not sure why they have a path.) For each player and each discretized grid point, compute and store the closest city. Then a troop can use int() to get its grid point, and look up the closest city without looping. There's a lot of magic numbers in there. Am I worried that they're not given names? No, not really. It does worry me that they don't show up in the player documentation, and as a player it's hard for me to visualize those interactions on-screen. I can't tell if a unit I just moved is now in or out of range for one or another of those special cases. In the Game constructor, 1200 certainly deserves a name with PORT in it. mutable defaults def __init__(self, ..., path=None): ... self.path = path if path is not None else [] Thank you for avoiding a mutable Troop default. The usual idiom here is slightly more compact: self.path = path or [] In the general case we'd need what you wrote, if e.g. path could take on a "falsey" value like 0.0. But here it's only the len() of a list that matters. It seems odd you're caching id(self), since you could cheaply ask for it any time you need it. number of players I propose that there's six players, all the time. But initially they're all inactive. And as TCP clients join, they claim the first inactive player. This makes joining a game after some delay more flexible, and reduces the interactive questions we have to ask up front. a boolean variable is already a boolean ... or self.done == True Prefer a simple ... or self.done. Also, looking at {ready, started, done}, perhaps we have more state variables than needed? For example, the "sleep 100 msec till self.started" loop could perhaps be removed if we deferred creating Players and threads until all had joined. Also, I'm not quite understanding self.done. When Blue defeats all Red cities and units, the game continues to play, rather than declaring victory. main guard wod_server.py contains quite a lot of code, and that is fine. But we don't have the traditional if __name__ == "__main__": guard, so automated unit tests cannot safely import this module without undesired side effects, and that is a problem. contract The wod_client interp() function really needs a docstring. What promises is it making to the caller? And marching_squares() needs either a docstring or at least some comments, perhaps a literature citation. Also, in handle_events() and in draw() we need to break out some helpers. "values" TERRAIN_VALUES = { "water": -0.1, "plains": 0.1, "hill": 0.7, "mountain": 0.83, } Yes, each of those four is a numeric "value". But tell us what those numbers mean. Do they relate to vision? To mobility? COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] Consider defining some Enums or some MANIFEST_CONSTANTS, and then put named colors into that list. gameplay grid markings Distance between units plays an important role in this game. Consider superimposing a purely cosmetic square or hex grid upon the display, to help folks visually judge relative distances. Paper maps often have a "12 miles to the inch" type of legend in the corner, with some black & white markings that let you put your thumb first here, then there, and say it's so many miles to destination. If there's no visible grid, consider putting some standard size markings in a corner legend. within range I have some idea of which units are interacting (killing) which other units. But it's not completely obvious, and I was surprised at how many special cases the server code handles. I feel like there's enough information available to the client module that it could choose to render orange lighting zaps coming from an attacking unit, that sort of thing. To support this, we would need a common library module which both server and client import, describing the damage model. There's two things I care about for a unit: health (damage), and rate of damage (or rate of healing) With the current game display, I have a hard time knowing which units in the fray are actually interacting, and if I have pulled a damaged unit "far enough" away from the melee. There is room to reveal derivative of health, IDK maybe with a simple left/right arrow to show it is dwindling or increasing. random damage After Blue defeats all Red cities and troops, I still observe Blue deaths, and respawns at cities. I cannot explain the damage / death events. update speed We already track FPS. I wouldn't mind seeing a one-line speed report that appears once per minute. It could mention how much usermode CPU we've been using recently. I would love to know about episodes where we asked for an unusually small sleep() time. There's no automated unittest for a server update cycle, and that makes it hard to profile the server code to see where the hot spots are. A test could include a scaling parameter, so we compare elapsed time on small vs. large maps or army sizes. It is often the case that "most" troops lack an active path. During profiling I would want to know if a "keep the status quo" approach is a good fit for such idle troops, so they're updated less often. The marching squares (visibility) calculations could perhaps be scheduled less often than troop updates are, without being visually jarring. Every nth frame might suffice. summary score Maybe display total number of per-player troops? (Or does that only become of interest once a player loses all cities?) Total number of "deployed" (out-of-city) troops? Total deaths? Maybe display the current aggregate damage (or "heal") rate for each player? Maybe a mouse-over on a troop would reveal its damage rate? Could be a tooltip near the pointer, but probably better to bury it in a corner display near the legend, so it's less distracting. Share Follow edited Feb 7 at 16:05 answered Feb 6 at 21:36 J_H's user avatar J_H 46k33 gold badges4141 silver badges167167 bronze badges is the overhead of converting ndarray to list and back for json worth using ndarrays? (great answer btw +1 and likely +50) – coder CommentedFeb 7 at 0:37 1 "overhead of converting ndarray to list and back for json" Surely we don't do that 45 times per second, right? In any event, nobody cares what one person's opinion might be. It sounds like we want to Extract Helper so the pygame loop can call into the same update() that a unit test or timeit() calls into. And then "worth it" is just a matter of comparing one elapsed time against another. // I happened to be playing Blue. I'm happy to play Red until I witness Blue's extinction, and I imagine I will see a similar "Red troops die / respawn even with no enemy troops alive" effect. – J_H CommentedFeb 7 at 1:35 do what you like with testing its your time/answer || ill look into sending numpy info, it probably will end up worth doing for performance because the brush apply could be improved with numpy for sure, the main performance issue it seems rn 1269497 72.511 0.000 115.930 0.000 wod_server.py:58(apply) – coder CommentedFeb 7 at 1:46 1 Ok, issue 2 shows the relevant screenshot for "unexpected damage". Automated unit tests would help with narrowing this down. – J_H CommentedFeb 7 at 4:26 Add a comment 7 +100 UX When I run the wod_server.py code, I get this prompt: Enter number of players (2-6): That is easy to understand. Then I get this prompt: Enter port to use (0 - 99): But, I am not sure what the numbers mean. The prompt could be a little more specific. It would be better to print a few lines of information before the prompts, giving the user more context. Briefly describe the game to someone who has never played it. You could also offer an option to bypass the introduction when you run the code (for users who have already played it). The code also appears hung for me at this line of output: waiting for players... I don't know how to proceed to actually play the game. Perhaps the code is waiting for someone to run the wod_client.py code. If that is the case, it would be good to explicitly state it. When I run wod_client.py, I get a GUI window, but it is just a black screen, and I don't know what to do. Documentation The PEP 8 style guide recommends adding docstrings for classes and functions. Consider using type hints to describe input and return types of the functions to make the code even more self-documenting. Comments Comments are intended to describe the code, not question it: def xy_to_dir_dis(xy): # is this the same as "xy[0] ** 2 + xy[1] ** 2" ? That comment should be deleted. Simpler I believe this line: (0 - xy[0]) ** 2 + (0 - xy[1]) ** 2 can be simplified as: xy[0] ** 2 + xy[1] ** 2 Give it a try. Also, instead of math.sqrt, you can simplify further using hypot: def xy_to_dir_dis(xy): return math.degrees(math.atan2(xy[1], xy[0])), math.hypot(xy[0], xy[1]) hypot can also be used in the elevation_bias function. When I see lines like: self.grid = [[0.0 for _ in range(COLS + 1)] for _ in range(ROWS + 1)] I wonder if the code could be simplified by using numpy, which may also have performance benefits. Main guard It is customary to add a "main" guard at the end of the code in file wod_server.py: if __name__ == '__main__': try: PLAYERS = int(input("Enter number of players (2-6): ")) if PLAYERS < 2 or PLAYERS > 6: print("Invalid number of players, defaulting to 2") PLAYERS = 2 except ValueError: print("Invalid number of players, defaulting to 2") game_play = Game() game_play.run_game() Naming PEP 8 only recommends all caps for constants, not variables. In wod_server.py, PLAYERS would be players. Tools You could run code development tools to automatically find some style issues with your code. ruff identifies things like this in wod_server.py: E714 [*] Test for object identity should be `is not` | | self.city_border_brush.apply(player.border, city.position, 1.0) | for other_player in self.players: | if not player is other_player: | ^^^^^^^^^^^^^^^^^^^^^^ E714 | for city in self.cities: | if city.owner is other_player: | = help: Convert to `is not` Share Follow answered Feb 3 at 12:50 toolic's user avatar toolic 21.9k66 gold badges3232 silver badges270270 bronze badges Add a comment 5 Context managers Both your Server and Client classes in simple_socket.py have close functions. I can't help but think that maybe these classes should implement the behaviors of a context manager so you don't necessarily have to explicitly manage this. Boolean comparisons Don't do this. self.done == True Simpler: self.done Early exits In simple_socket.py you have a function: def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if msg_length: msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) return "" I can't help but think this reads better with an early return in the event msg_length is false. def rcv(self): msg_length = self.client.recv(HEADER).decode(FORMAT) total_received = 0 if not msg_length: return "" msg = [] while total_received < int(msg_length): data = self.client.recv(int(msg_length)) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) You also never use msg_length except after converting it to an int so rather than repeating yourself: def rcv(self): msg_length = int(self.client.recv(HEADER).decode(FORMAT)) total_received = 0 if not msg_length: return "" msg = [] while total_received < msg_length: data = self.client.recv(msg_length) total_received += len(data) msg.append(data) return b"".join(msg).decode(FORMAT) DRY In the following I see a lot of repetition in setting self.players. You may wish to create a list of players once and then assign slices of that list to self.players depending on the value of PLAYERS. if PLAYERS == 2: self.players = [ Player(left_city.position, COLORS[0], self), Player(right_city.position, COLORS[1], self), ] left_city.owner = self.players[0] right_city.owner = self.players[1] elif PLAYERS == 3: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(right_bottom_city.position, COLORS[1], self), Player(top_city.position, COLORS[2], self), ] left_bottom_city.owner = self.players[0] right_bottom_city.owner = self.players[1] top_city.owner = self.players[2] elif PLAYERS == 4: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(top_right_city.position, COLORS[2], self), Player(right_bottom_city.position, COLORS[3], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] top_right_city.owner = self.players[2] right_bottom_city.owner = self.players[3] elif PLAYERS == 5: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_city.position, COLORS[2], self), Player(top_right_city.position, COLORS[3], self), Player(right_bottom_city.position, COLORS[4], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_city.owner = self.players[2] top_right_city.owner = self.players[3] right_bottom_city.owner = self.players[4] elif PLAYERS == 6: self.players = [ Player(left_bottom_city.position, COLORS[0], self), Player(top_left_city.position, COLORS[1], self), Player(middle_top_city.position, COLORS[2], self), Player(middle_bottom_city.position, COLORS[3], self), Player(top_right_city.position, COLORS[4], self), Player(right_bottom_city.position, COLORS[5], self), ] left_bottom_city.owner = self.players[0] top_left_city.owner = self.players[1] middle_top_city.owner = self.players[2] middle_bottom_city.owner = self.players[3] top_right_city.owner = self.players[4] right_bottom_city.owner = self.players[5] Share Follow answered Feb 3 at 15:54 Chris's user avatar Chris 18.2k11 gold badge1414 silver badges9292 bronze badges Add a comment 5 I have not attempted to run this program, so these are just some random thoughts after performing a cursory review. Enum First of all, you deal with tuples a lot, so using namedtuple would make sense at some places. Enums are underutilized as well. For example, instead of: COLORS = [(255, 0, 0), (0, 0, 255), (255, 150, 0), (175, 0, 175), (0, 175, 0), (0, 255, 255)] I would suggest this approach: from enum import Enum class Color(Enum): RED = 255, 0, 0 GREEN = 0, 255, 0 BLUE = 0, 0, 255 WHITE = 255, 255, 255 BLACK = 0, 0, 0 print(Color.BLUE) Then you can use expressive color names, that make the code immediately more descriptive. In fact, the parentheses are not needed here, you can remove them. I have not bothered to introduce a full RGB class, this setup is sufficient for your purpose. You could also use namedtuple for coordinates. As shown in another question from collections import namedtuple Point = namedtuple('Point', 'x y', defaults=[0, 0]) I regret the lack of comments. Since the code is fairly long, and without being privy to the game, the flow is not easy to follow without testing the program and studying the rules of the game. From time to time, it would be helpful to comment blocks of code, to explain what you are doing at this specific point and why. Naming Function names are not always intuitive. For example, update_cities does not tell me what exactly we are doing here. Other names like xy_to_dir_dis are somewhat cryptic. Don't be afraid to use longer names, there is no bonus for abbreviating things. To take a random example: def apply(self, marching_squares, pos, target_value): mx, my = pos cs = CELL_SIZE r = float(self.radius) if r <= 0: return col_start = max(0, int((my - r) / cs)) col_end = min(COLS, int((my + r) / cs) + 1) row_start = max(0, int((mx - r) / cs)) row_end = min(ROWS, int((mx + r) / cs) + 1) You are assigning a new variable cs, which takes the value of CELL_SIZE. Just for the sake of abbreviating. That only obfuscates the code. Likewise, self.radius is much more expressive than just r, which could mean anything. And you could explicitly cast self.radius to float in your __init__ method if that matters. But many variable names are very short, which does not facilitate comprehension, and can even be a cause of bugs in my opinion. Class Some classes like Troop, City, Player should be made @dataclass to reduce boilerplate a little bit. In class Environment, the assignment of self.players is quite dense and there is repeat code. I think it would be interesting to explore dataclass and especially the default_factory method. Suggested article which I found helpful: Python Data Classes: A Comprehensive Tutorial Share Follow answered Feb 3 at 17:37 Kate's user avatar Kate 11k1010 silver badges3030 bronze badges Add a comment You must log in to answer this question. Start asking to get answers Find the answer to your question by asking. Explore related questions pythonperformancepygamebattle-simulation See similar questions with these tags. The Overflow Blog Your LLM issues are really data issues Welcome to the “find out” stage of AI Report this ad Report this ad Linked 5 YouTube Downloader implementation using CustomTkinter and Pytubefix 9 Maze Solver in Python inspired by Micro-Mouse Competition Logic 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Related 2 War card game simulator 12 Script for a Civil War game 9 Simple top down shooter game 3 Python 4 Players Snake Game 6 Python/Pygame Fighting Game 5 Simple Python Pygame Game 2 Card game of war using pygame module 5 Very simple Flappy 'Bird' game - First project in Python 7 War of dots (server): A simple RTS war game in python 3 War of dots (client): A simple RTS war game in python Hot Network Questions Which type of barrel adjuster is missing here? First book of series might leave a bad impression; rewrite or not? What precedent did the 2 Live Crew case actually set? How can we obtain a smoother sphere when cutting it with a plane and moving the cut portion? Non-quasi-separated perfectoid spaces more hot questions Question feed Code Review Tour Help Chat Contact Feedback Company Stack Overflow Stack Internal Stack Data Licensing Stack Ads About Press Legal Privacy Policy Terms of Service Your Privacy Choices Cookie Policy Stack Exchange Network Technology Culture & recreation Life & arts Science Professional Business API Data Blog Facebook Twitter LinkedIn Instagram Site design / logo © 2026 Stack Exchange Inc; user contributions licensed under CC BY-SA . rev 2026.4.23.42490