#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ASCII Minecraft-Style Survival (Full LAN Multiplayer)
Features:
• Huge worlds (Overworld 1000×200, Nether 240×120, End 300×160)
• 80×20 scrolling viewport
• Up to 10 mobs at once
• Safe Tkinter callbacks to prevent crashes
• Automatic LAN host/discovery: true 2-player sync
"""
import tkinter as tk
from tkinter import ttk
import math, random, time, socket, threading, pickle, os
# ----------------------------
# GLOBAL SETTINGS
# ----------------------------
OVERWORLD_WIDTH, OVERWORLD_HEIGHT = 1000, 200
NETHER_WIDTH, NETHER_HEIGHT = 240, 120
END_WIDTH, END_HEIGHT = 300, 160
VIEWPORT_WIDTH, VIEWPORT_HEIGHT = 80, 20
CELL_SIZE = 10
DAY_LENGTH = 120.0
START_TIME = time.time()
STACK_LIMIT = 64
DIMENSIONS = ["overworld", "nether", "end"]
MAX_MOBS = 10
NETWORK_PORT = 50000
# ----------------------------
# BLOCK COLORS & PLACEABLE
# ----------------------------
BLOCKS = {
"AIR":"#FFFFFF","GRASS":"#00AA00","DIRT":"#8B4513","STONE":"#808080",
"COAL_ORE":"#333333","IRON_ORE":"#CC6600","DIAMOND_ORE":"#55FFFF",
"WOOD":"#A0522D","LEAF":"#66CC66","OBSIDIAN":"#440044","PORTAL":"#0000FF",
"NETHERRACK":"#A52A2A","END_STONE":"#D2B48C"
}
PLACEABLE = set(BLOCKS) - {"AIR"}
# ----------------------------
# PLAYER & ENEMY CLASSES
# ----------------------------
class Player:
def __init__(self, x, y, dim="overworld"):
self.x = x; self.y = y
self.health = 100; self.hunger = 100
self.dimension = dim
self.inventory = {b:0 for b in BLOCKS if b!="AIR"}
self.active_item = None
self.orientation = 0
class Enemy:
def __init__(self, x, y, kind="zombie"):
self.x = x; self.y = y; self.kind = kind
self.health = 20
self.sym = "Z" if kind=="zombie" else "B"
# ----------------------------
# GAME CLASS
# ----------------------------
class Game:
def __init__(self, root):
self.root = root
root.title("ASCII Minecraft Multiplayer")
# Canvas
self.canvas = tk.Canvas(root,
width=VIEWPORT_WIDTH*CELL_SIZE,
height=VIEWPORT_HEIGHT*CELL_SIZE,
bg="black")
self.canvas.pack(side=tk.LEFT)
self.status_var = tk.StringVar()
ttk.Label(root, textvariable=self.status_var).pack(fill=tk.X)
# Safe callbacks
self.canvas.bind("<KeyPress>", self.safe_callback(self.on_key))
self.canvas.bind("<Button-1>", self.safe_callback(self.on_click))
self.canvas.bind("<Motion>", self.safe_callback(self.on_mouse_move))
self.canvas.bind("<MouseWheel>", self.safe_callback(self.on_wheel))
self.canvas.bind("<Button-4>", self.safe_callback(self.on_wheel))
self.canvas.bind("<Button-5>", self.safe_callback(self.on_wheel))
# Game state
self.worlds = {}
self._gen_all_worlds()
self.player = Player(*self._center("overworld"), "overworld")
self.enemies = []
self.other_players = {}
self.player_id = random.randint(1,1<<30)
# Networking
self.is_host = False
self.conn = None
self.start_network()
# Begin loop
self.last_time = time.time()
self.update_game()
self.canvas.focus_set()
# ----------------------------
# SAFE CALLBACK DECORATOR
# ----------------------------
@staticmethod
def safe_callback(fn):
def wrapper(self, *a, **k):
try:
return fn(self, *a, **k)
except Exception as e:
print(f"[Callback error] {fn.__name__}: {e}")
return wrapper
# ----------------------------
# WORLD GENERATION
# ----------------------------
def _gen_all_worlds(self):
for dim in DIMENSIONS:
w,h = self._world_size(dim)
grid = [["AIR"]*w for _ in range(h)]
if dim=="overworld":
for y in range(h):
for x in range(w):
if y<40: grid[y][x]="AIR"
elif y==40: grid[y][x]="GRASS"
elif y<45: grid[y][x]="DIRT"
else:
r=random.random()
if r<0.05: grid[y][x]="COAL_ORE"
elif r<0.08: grid[y][x]="IRON_ORE"
elif r<0.09: grid[y][x]="DIAMOND_ORE"
else: grid[y][x]="STONE"
elif dim=="nether":
for y in range(h):
for x in range(w):
grid[y][x] = "NETHERRACK"
else: # end
for y in range(h):
for x in range(w):
grid[y][x] = "END_STONE" if y>h-8 else "AIR"
self.worlds[dim] = grid
def _world_size(self, dim):
if dim=="overworld": return OVERWORLD_WIDTH, OVERWORLD_HEIGHT
if dim=="nether": return NETHER_WIDTH, NETHER_HEIGHT
return END_WIDTH, END_HEIGHT
def _center(self, dim):
w,h = self._world_size(dim)
return w//2, h//2
# ----------------------------
# NETWORKING (auto-host/join + sync)
# ----------------------------
def start_network(self):
# UDP listen
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.settimeout(0.5)
udp.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
udp.bind(('', NETWORK_PORT))
try:
data, addr = udp.recvfrom(1024)
if data==b"MC_HOST":
self._connect_to_host(addr[0])
return
except socket.timeout:
pass
# become host
self.is_host = True
threading.Thread(target=self._broadcast_host, daemon=True).start()
threading.Thread(target=self._accept_client, daemon=True).start()
def _broadcast_host(self):
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
for _ in range(5):
udp.sendto(b"MC_HOST", ('<broadcast>', NETWORK_PORT))
time.sleep(0.2)
def _accept_client(self):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.bind(('', NETWORK_PORT))
srv.listen(1)
conn,_ = srv.accept()
self.conn = conn
threading.Thread(target=self._handle_client, daemon=True).start()
def _connect_to_host(self, ip):
cli = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
cli.connect((ip, NETWORK_PORT))
self.conn = cli
threading.Thread(target=self._handle_server, daemon=True).start()
def _handle_client(self):
# host: recv client, send back full state
while True:
data = self.conn.recv(4096)
if not data: break
cstate = pickle.loads(data)
pid = cstate['pid']
self.other_players[pid] = cstate['ply']
# build full state
full = {
'players': { self.player_id: self._serialize(self.player),
pid: cstate['ply'] },
'enemies': [(e.x,e.y,e.kind) for e in self.enemies]
}
self.conn.sendall(pickle.dumps(full))
def _handle_server(self):
# client: send own state, apply full state
while True:
state = {'pid':self.player_id,
'ply':self._serialize(self.player)}
self.conn.sendall(pickle.dumps(state))
data = self.conn.recv(65536)
if not data: break
full = pickle.loads(data)
for pid,p in full['players'].items():
if pid==self.player_id: continue
# apply other players
o = self.other_players.get(pid) or Player(0,0)
o.x, o.y = p['x'], p['y']
self.other_players[pid] = o
# update enemies
self.enemies = [Enemy(x,y,k) for x,y,k in full['enemies']]
time.sleep(0.1)
def _serialize(self, p):
return {'x':p.x,'y':p.y,'dim':p.dimension}
# ----------------------------
# DRAWING
# ----------------------------
def get_camera(self):
w,h = self._world_size(self.player.dimension)
cx = max(0, min(self.player.x - VIEWPORT_WIDTH//2, w - VIEWPORT_WIDTH))
cy = max(0, min(self.player.y - VIEWPORT_HEIGHT//2, h - VIEWPORT_HEIGHT))
return cx, cy
def draw_world(self):
self.canvas.delete("all")
cx,cy = self.get_camera()
grid = self.worlds[self.player.dimension]
# draw tiles
for y in range(VIEWPORT_HEIGHT):
for x in range(VIEWPORT_WIDTH):
bx,by = cx+x, cy+y
blk = grid[by][bx]
color = BLOCKS.get(blk,"#000")
self.canvas.create_rectangle(
x*CELL_SIZE, y*CELL_SIZE,
(x+1)*CELL_SIZE,(y+1)*CELL_SIZE,
fill=color, outline="gray")
# draw self (red)
self._draw_player(self.player, cx, cy, "red")
# draw others (blue)
for o in self.other_players.values():
self._draw_player(o, cx, cy, "blue")
# draw enemies (black)
for e in self.enemies:
self._draw_simple(e.x, e.y, cx, cy, "black")
self.canvas.update()
def _draw_player(self, p, cx, cy, col):
vx,vy = p.x-cx, p.y-cy
pad=2
if 0<=vx<VIEWPORT_WIDTH and 0<=vy<VIEWPORT_HEIGHT:
self.canvas.create_oval(
vx*CELL_SIZE+pad, vy*CELL_SIZE+pad,
(vx+1)*CELL_SIZE-pad,(vy+1)*CELL_SIZE-pad,
fill=col, outline="white")
def _draw_simple(self, wx, wy, cx, cy, col):
vx,vy = wx-cx, wy-cy
pad=2
if 0<=vx<VIEWPORT_WIDTH and 0<=vy<VIEWPORT_HEIGHT:
self.canvas.create_oval(
vx*CELL_SIZE+pad, vy*CELL_SIZE+pad,
(vx+1)*CELL_SIZE-pad,(vy+1)*CELL_SIZE-pad,
fill=col, outline=col)
# ----------------------------
# INPUT HANDLERS
# ----------------------------
def on_key(self, e):
k=e.keysym.lower()
if k in ("w","a","s","d"):
dx = (k=="d") - (k=="a")
dy = (k=="s") - (k=="w")
self._try_move(dx, dy)
self.draw_world()
def on_click(self, e):
cx,cy = self.get_camera()
gx,gy = cx + e.x//CELL_SIZE, cy + e.y//CELL_SIZE
grid = self.worlds[self.player.dimension]
if 0<=gx<len(grid[0]) and 0<=gy<len(grid):
# break or place
if grid[gy][gx]!="AIR":
if len(self.enemies)<MAX_MOBS and random.random()<0.5:
# spawn an enemy instead of pickup
pass
grid[gy][gx]="AIR"
else:
if self.player.active_item in PLACEABLE:
grid[gy][gx] = self.player.active_item
self.draw_world()
def on_mouse_move(self, e):
pass # orientation not used here
def on_wheel(self, e):
pass # no inventory in this skeleton
def _try_move(self, dx, dy):
w,h = self._world_size(self.player.dimension)
nx,ny = self.player.x+dx, self.player.y+dy
grid = self.worlds[self.player.dimension]
if 0<=nx<w and 0<=ny<h and grid[ny][nx]=="AIR":
self.player.x, self.player.y = nx, ny
# ----------------------------
# MAIN GAME LOOP
# ----------------------------
def update_game(self):
now = time.time()
# spawn up to MAX_MOBS in overworld
if (self.player.dimension=="overworld"
and len(self.enemies)<MAX_MOBS
and random.random()<0.01):
ex = random.randint(0, OVERWORLD_WIDTH-1)
ey = random.randint(45, OVERWORLD_HEIGHT-1)
if abs(ex-self.player.x)>5 and abs(ey-self.player.y)>5:
self.enemies.append(Enemy(ex, ey))
# simple enemy move
for e in self.enemies:
dx = (1 if e.x<self.player.x else -1 if e.x>self.player.x else 0)
dy = (1 if e.y<self.player.y else -1 if e.y>self.player.y else 0)
self._move_enemy(e, dx, dy)
# redraw & reschedule
self.draw_world()
elapsed = now - START_TIME
phase = "Day" if (elapsed % DAY_LENGTH)<=DAY_LENGTH/2 else "Night"
self.status_var.set(f"Dim:{self.player.dimension} | {phase} | Mobs:{len(self.enemies)}")
self.root.after(100, self.update_game)
def _move_enemy(self, e, dx, dy):
grid = self.worlds[self.player.dimension]
w,h = self._world_size(self.player.dimension)
nx,ny = e.x+dx, e.y+dy
if 0<=nx<w and 0<=ny<h and grid[ny][nx]=="AIR":
e.x, e.y = nx, ny
# ----------------------------
# LAUNCH THE GAME
# ----------------------------
if __name__ == "__main__":
root = tk.Tk()
game = Game(root)
root.mainloop()