AI vs AI Tetris (PyScript)

150 ms
Press "Start Match" to begin the simulation. Gray blocks (█) are garbage lines sent by clearing multiple lines.

🎙️ Sports Commentator

Waiting for match to start...

AI-1


  

AI-2


  

🎲 Figure Bank


🎯 Prediction League

Make your prediction before the match starts! Track your accuracy and compete for the monthly leaderboard. No money involved — just bragging rights and community recognition!

Who will win this match?

Historical odds: AI-1: 50% | AI-2: 50% (based on 0 past matches)

Your Stats

Predictions: 0
Correct: 0
Accuracy: 0%
Current prediction: None
📊 How Prediction League Works

🎮 It's FREE and FUN!

  • Make predictions before each match starts
  • Build your accuracy statistics over time
  • Compete on monthly leaderboards (coming soon!)
  • Learn AI strategies by studying match patterns

🏆 Monthly Prizes (Ethical!):

Top 3 predictors receive a $50 GitHub Sponsorship in their name to an open-source project of their choice! 100% funded by YouTube ad revenue and Patreon supporters.

No gambling, no money changing hands. Just skill, strategy knowledge, and community support! 💎

🏆 Tournament Mode

Results

Match # Winner AI-1 Score AI-2 Score Turns Reason
Summary:
packages = [] import asyncio import random import time from js import document from pyodide.ffi import create_proxy # --- Core data structures ---------------------------------------------------- SHAPES = { "I": [[1, 1, 1, 1]], "O": [[1, 1], [1, 1]], "T": [[0, 1, 0], [1, 1, 1]], "S": [[0, 1, 1], [1, 1, 0]], "Z": [[1, 1, 0], [0, 1, 1]], "L": [[1, 0], [1, 0], [1, 1]], "J": [[0, 1], [0, 1], [1, 1]], } # Classic Tetris colors (The Tetris Company standard) COLORS = { "I": "#00f0f0", # Cyan "O": "#f0f000", # Yellow "T": "#a000f0", # Purple "S": "#00f000", # Green "Z": "#f00000", # Red "L": "#f0a000", # Orange "J": "#0000f0", # Blue } TETROMINO_TYPES = list(SHAPES.keys()) def clone_shape(shape): return [row[:] for row in shape] class Tetromino: def __init__(self, shape_type: str): self.type = shape_type self.shape = clone_shape(SHAPES[shape_type]) self.rotation = 0 def rotate(self): rotated = [list(row) for row in zip(*self.shape[::-1])] self.shape = rotated self.rotation = (self.rotation + 1) % 4 def get_width(self): return len(self.shape[0]) if self.shape else 0 def get_height(self): return len(self.shape) class GameBoard: def __init__(self, width: int = 10, height: int = 20): self.width = width self.height = height self.grid = [[None for _ in range(width)] for _ in range(height)] self.score = 0 self.lines_cleared = 0 self.game_over = False def can_place(self, piece: Tetromino, x: int, y: int) -> bool: for row_idx, row in enumerate(piece.shape): for col_idx, cell in enumerate(row): if not cell: continue board_y = y + row_idx board_x = x + col_idx if board_x < 0 or board_x >= self.width: return False if board_y < 0 or board_y >= self.height: return False if self.grid[board_y][board_x]: return False return True def place_piece(self, piece: Tetromino, x: int, y: int): for row_idx, row in enumerate(piece.shape): for col_idx, cell in enumerate(row): if cell: board_y = y + row_idx board_x = x + col_idx if 0 <= board_y < self.height and 0 <= board_x < self.width: self.grid[board_y][board_x] = piece.type def clear_lines(self) -> int: completed = [index for index, row in enumerate(self.grid) if all(row)] for idx in completed: del self.grid[idx] self.grid.insert(0, [None for _ in range(self.width)]) cleared = len(completed) if cleared: self.lines_cleared += cleared score_table = {1: 100, 2: 300, 3: 500, 4: 800} self.score += score_table.get(cleared, cleared * 100) return cleared def add_garbage_lines(self, num_lines: int): for _ in range(num_lines): self.grid.pop(0) garbage = ['G' for _ in range(self.width)] hole = random.randint(0, self.width - 1) garbage[hole] = None self.grid.append(garbage) def is_game_over(self) -> bool: return any(self.grid[0]) def get_height_map(self): heights = [] for x in range(self.width): column_height = 0 for y in range(self.height): if self.grid[y][x]: column_height = self.height - y break heights.append(column_height) return heights def get_max_height(self): heights = self.get_height_map() return max(heights) if heights else 0 def to_string(self) -> str: """Generate ASCII board with HTML color styling.""" lines = ["+" + "-" * self.width + "+"] for row in self.grid: line = "|" for cell in row: if cell: if cell == 'G': # Gray for garbage line += '' else: # Colored block for each piece type color = COLORS.get(cell, "#39ff14") line += '' else: line += " " line += "|" lines.append(line) lines.append("+" + "-" * self.width + "+") return "\n".join(lines) class FigureBank: def __init__(self, initial_count: int = 15): self.bank = {piece: initial_count for piece in TETROMINO_TYPES} def get_piece(self, piece_type: str) -> bool: if piece_type not in self.bank: return False if self.bank[piece_type] > 0: self.bank[piece_type] -= 1 return True return False def is_available(self, piece_type: str) -> bool: return piece_type in self.bank and self.bank[piece_type] > 0 def get_available_pieces(self): return [piece for piece in TETROMINO_TYPES if self.bank[piece] > 0] def is_empty(self) -> bool: return all(count == 0 for count in self.bank.values()) def get_random_available(self) -> str | None: available = self.get_available_pieces() if available: return random.choice(available) return None def get_state(self): return self.bank.copy() class AIAgent: def __init__(self, name: str, strategy: str = "greedy"): self.name = name self.strategy = strategy self.decision_times = [] def decide_placement(self, board: GameBoard, piece: Tetromino, verbose=False): start_time = time.time() best_position = None best_score = float("-inf") all_evaluations = [] # Store all considered positions for rotation in range(4): piece_copy = Tetromino(piece.type) for _ in range(rotation): piece_copy.rotate() for x in range(board.width - piece_copy.get_width() + 1): y = 0 while y < board.height and board.can_place(piece_copy, x, y): y += 1 y -= 1 if y >= 0 and board.can_place(piece_copy, x, y): score, metrics = self._evaluate_position(board, piece_copy, x, y, return_metrics=True) all_evaluations.append({ "x": x, "y": y, "rotation": rotation, "score": score, "metrics": metrics }) if score > best_score: best_score = score best_position = (x, y, rotation) decision_time = time.time() - start_time self.decision_times.append(decision_time) # Store thinking process for display if verbose and all_evaluations: self.last_thinking = { "piece": piece.type, "evaluations": sorted(all_evaluations, key=lambda e: e["score"], reverse=True)[:5], "best": best_position, "best_score": best_score, "total_options": len(all_evaluations), "decision_time": decision_time } if best_position: return best_position return (board.width // 2, 0, 0) def _evaluate_position(self, board: GameBoard, piece: Tetromino, x: int, y: int, return_metrics=False): test_board = GameBoard(board.width, board.height) test_board.grid = [row[:] for row in board.grid] test_board.place_piece(piece, x, y) max_height = test_board.get_max_height() heights = test_board.get_height_map() aggregate_height = sum(heights) bumpiness = sum(abs(heights[i] - heights[i + 1]) for i in range(len(heights) - 1)) holes = 0 for col_x in range(board.width): found_block = False for row_y in range(board.height): if test_board.grid[row_y][col_x]: found_block = True elif found_block: holes += 1 lines_cleared = self._count_potential_lines(test_board) if self.strategy == "greedy": score = -aggregate_height * 4.0 - holes * 7.0 - bumpiness * 3.0 + lines_cleared * 2.0 elif self.strategy == "defensive": score = -aggregate_height * 3.0 - holes * 10.0 - bumpiness * 6.0 + lines_cleared * 1.5 elif self.strategy == "aggressive": score = aggregate_height * 3.0 - holes * 5.0 + bumpiness * 0.5 + lines_cleared * 5.0 else: score = -aggregate_height - holes if return_metrics: return score, { "height": aggregate_height, "holes": holes, "bumpiness": bumpiness, "lines": lines_cleared } return score def _count_potential_lines(self, board: GameBoard) -> int: return sum(1 for row in board.grid if all(row)) def choose_attack_piece(self, bank: FigureBank): available = bank.get_available_pieces() if not available: return None if self.strategy == "aggressive": difficult = [p for p in ["T", "L", "J"] if p in available] if difficult: return random.choice(difficult) elif self.strategy == "defensive": easy = [p for p in ["I", "O"] if p in available] if easy: return random.choice(easy) return random.choice(available) class Arena: def __init__(self, ai1: AIAgent, ai2: AIAgent, bank: FigureBank, max_turns: int = 360): self.ai1 = ai1 self.ai2 = ai2 self.board1 = GameBoard() self.board2 = GameBoard() self.bank = bank self.max_turns = max_turns self.turn = 0 self.current_piece_ai1 = self._get_next_piece() self.current_piece_ai2 = self._get_next_piece() self.garbage_queue_ai1 = 0 self.garbage_queue_ai2 = 0 def _get_next_piece(self) -> str: piece_type = self.bank.get_random_available() if piece_type: self.bank.get_piece(piece_type) return piece_type return random.choice(TETROMINO_TYPES) def play_turn(self, ai: AIAgent, board: GameBoard, piece_type: str, verbose=False): piece = Tetromino(piece_type) x, y, rotations = ai.decide_placement(board, piece, verbose=verbose) for _ in range(rotations): piece.rotate() if board.can_place(piece, x, y): board.place_piece(piece, x, y) else: board.game_over = True return 0, 0.0, None lines_cleared = board.clear_lines() attack_piece = None if lines_cleared > 0: attack_piece = ai.choose_attack_piece(self.bank) decision_time = ai.decision_times[-1] if ai.decision_times else 0.0 return lines_cleared, decision_time, attack_piece def _result_state(self, finished: bool, winner: str | None, reason: str): return { "finished": finished, "winner": winner, "reason": reason, "turn": self.turn, "scores": {"ai1": self.board1.score, "ai2": self.board2.score}, "lines": {"ai1": self.board1.lines_cleared, "ai2": self.board2.lines_cleared}, } def _winner_by_score(self) -> str: if self.board1.score == self.board2.score: if self.board1.lines_cleared >= self.board2.lines_cleared: return self.ai1.name return self.ai2.name if self.board1.score > self.board2.score: return self.ai1.name return self.ai2.name def step(self, verbose=False): if self.turn >= self.max_turns: winner = self._winner_by_score() result = self._result_state(True, winner, "turn_limit") result["move_data"] = None return result self.turn += 1 move_data = {"ai1": {}, "ai2": {}} self.current_thinking_ai = self.ai1.name # Track who's thinking if self.garbage_queue_ai1: self.board1.add_garbage_lines(self.garbage_queue_ai1) self.garbage_queue_ai1 = 0 prev_piece1 = self.current_piece_ai1 lines1, _, attack1 = self.play_turn(self.ai1, self.board1, self.current_piece_ai1, verbose=verbose) move_data["ai1"] = { "piece": prev_piece1, "lines": lines1, "attack": attack1, "board": self.board1 } if self.board1.game_over or self.board1.is_game_over(): result = self._result_state(True, self.ai2.name, "ai1_board_overflow") result["move_data"] = move_data return result if lines1 > 0: self.garbage_queue_ai2 += lines1 if attack1 and self.bank.is_available(attack1): self.current_piece_ai1 = attack1 self.bank.get_piece(attack1) else: self.current_piece_ai1 = self._get_next_piece() self.current_thinking_ai = self.ai2.name # Switch to AI-2 if self.garbage_queue_ai2: self.board2.add_garbage_lines(self.garbage_queue_ai2) self.garbage_queue_ai2 = 0 prev_piece2 = self.current_piece_ai2 lines2, _, attack2 = self.play_turn(self.ai2, self.board2, self.current_piece_ai2, verbose=verbose) move_data["ai2"] = { "piece": prev_piece2, "lines": lines2, "attack": attack2, "board": self.board2 } if self.board2.game_over or self.board2.is_game_over(): result = self._result_state(True, self.ai1.name, "ai2_board_overflow") result["move_data"] = move_data return result if lines2 > 0: self.garbage_queue_ai1 += lines2 if attack2 and self.bank.is_available(attack2): self.current_piece_ai2 = attack2 self.bank.get_piece(attack2) else: self.current_piece_ai2 = self._get_next_piece() if self.turn >= self.max_turns: winner = self._winner_by_score() result = self._result_state(True, winner, "turn_limit") result["move_data"] = move_data return result result = self._result_state(False, None, "ongoing") result["move_data"] = move_data return result # --- UI helpers -------------------------------------------------------------- def board_text(board: GameBoard) -> str: return board.to_string() def bank_text(bank: FigureBank) -> str: """Generate ASCII bank display with colored piece indicators.""" lines = ['🎲 FIGURE BANK'] state = bank.get_state() for piece in TETROMINO_TYPES: count = state[piece] color = COLORS.get(piece, "#39ff14") # Color the piece letter in its signature color colored_piece = '' + piece + '' # Dim the count if low if count <= 2: count_style = 'color: #ff3b30; font-weight: bold;' # Red for critical elif count <= 5: count_style = 'color: #ff9500; font-weight: bold;' # Orange for low else: count_style = 'color: #e2e2e2;' # Normal lines.append(f'{colored_piece}: {count:02d}') total = sum(state.values()) lines.append(f'Total: {total}') return "\n".join(lines) # --- Canvas Rendering -------------------------------------------------------- def draw_board_canvas(ctx, board: GameBoard): """Draw the game board on a canvas with colored pieces.""" cell_size = 20 ctx.fillStyle = "#000000" ctx.fillRect(0, 0, 200, 400) for y in range(board.height): for x in range(board.width): cell = board.grid[y][x] if cell: # Use piece-specific color, or gray for garbage if cell == 'G': ctx.fillStyle = "#808080" # Gray for garbage else: ctx.fillStyle = COLORS.get(cell, "#39ff14") ctx.fillRect(x * cell_size, y * cell_size, cell_size, cell_size) # Draw border with slight highlight effect ctx.strokeStyle = "#000000" ctx.lineWidth = 2 ctx.strokeRect(x * cell_size, y * cell_size, cell_size, cell_size) # Inner highlight for 3D effect ctx.strokeStyle = "rgba(255, 255, 255, 0.3)" ctx.lineWidth = 1 ctx.strokeRect(x * cell_size + 2, y * cell_size + 2, cell_size - 4, cell_size - 4) def draw_bank_canvas(ctx, bank: FigureBank): """Draw the figure bank on a canvas.""" ctx.fillStyle = "#000000" ctx.fillRect(0, 0, 420, 150) state = bank.get_state() pieces = list(TETROMINO_TYPES) bar_width = 50 bar_gap = 10 max_height = 100 max_count = 15 y_baseline = 130 for i, piece_type in enumerate(pieces): x = 10 + i * (bar_width + bar_gap) count = state[piece_type] bar_height = (count / max_count) * max_height if max_count > 0 else 0 # Draw bar with piece color (dimmed if low count) base_color = COLORS.get(piece_type, "#39ff14") if count <= 2: # Red tint for critically low ctx.fillStyle = "#ff3b30" elif count <= 5: # Orange tint for low ctx.fillStyle = "#ff9500" else: # Use piece's natural color ctx.fillStyle = base_color ctx.fillRect(x, y_baseline - bar_height, bar_width, bar_height) # Draw border ctx.strokeStyle = "#000000" ctx.lineWidth = 2 ctx.strokeRect(x, y_baseline - bar_height, bar_width, bar_height) # Draw piece type label with its color ctx.fillStyle = base_color ctx.font = "bold 16px monospace" ctx.textAlign = "center" ctx.fillText(piece_type, x + bar_width / 2, y_baseline + 18) # Draw count ctx.fillStyle = "#ffffff" ctx.font = "bold 14px monospace" ctx.fillText(str(count), x + bar_width / 2, y_baseline - bar_height - 8) # --- UI State Management ----------------------------------------------------- class MatchController: def __init__(self): self.arena = None self.is_running = False self.is_paused = False self.current_task = None self.match_stats = { "turns": [], "ai1_scores": [], "ai2_scores": [], "ai1_lines": [], "ai2_lines": [], } def update_ui(self): if self.arena: if use_canvas_view: draw_board_canvas(ctx1, self.arena.board1) draw_board_canvas(ctx2, self.arena.board2) draw_bank_canvas(ctx_bank, self.arena.bank) else: # Use innerHTML to render HTML color tags board1_element.innerHTML = board_text(self.arena.board1) board2_element.innerHTML = board_text(self.arena.board2) bank_element.innerHTML = bank_text(self.arena.bank) else: board1_element.innerHTML = "" board2_element.innerHTML = "" bank_element.innerHTML = "" def update_status(self, state): ai1_name = self.arena.ai1.name if self.arena else "AI-1" ai2_name = self.arena.ai2.name if self.arena else "AI-2" status_element.textContent = ( f"Turn {state['turn']} | Score {ai1_name}: {state['scores']['ai1']} | " f"Score {ai2_name}: {state['scores']['ai2']}" ) if state["finished"]: status_element.textContent += ( f" | Winner: {state['winner']} (reason: {state['reason']})" ) def collect_stats(self, state): self.match_stats["turns"].append(state["turn"]) self.match_stats["ai1_scores"].append(state["scores"]["ai1"]) self.match_stats["ai2_scores"].append(state["scores"]["ai2"]) self.match_stats["ai1_lines"].append(state["lines"]["ai1"]) self.match_stats["ai2_lines"].append(state["lines"]["ai2"]) def display_stats(self): if not self.arena: return ai1_avg_time = self.arena.ai1.get_average_decision_time() * 1000 ai2_avg_time = self.arena.ai2.get_average_decision_time() * 1000 stats_grid.innerHTML = f"""

AI-1 Final Score

{self.arena.board1.score}

AI-2 Final Score

{self.arena.board2.score}

AI-1 Lines Cleared

{self.arena.board1.lines_cleared}

AI-2 Lines Cleared

{self.arena.board2.lines_cleared}

AI-1 Avg Decision Time

{ai1_avg_time:.1f} ms

AI-2 Avg Decision Time

{ai2_avg_time:.1f} ms

Total Turns

{self.arena.turn}

Bank Pieces Remaining

{self.arena.bank.get_total_remaining()}
""" stats_section.style.display = "block" def update_buttons(self, running=None, paused=None, finished=False): if running is not None: self.is_running = running if paused is not None: self.is_paused = paused start_button.disabled = self.is_running pause_button.disabled = not self.is_running or self.is_paused or finished resume_button.disabled = not self.is_paused or finished step_button.disabled = not self.is_paused or finished restart_button.disabled = not self.is_running and not finished export_button.disabled = not finished controller = MatchController() start_button = document.getElementById("start") pause_button = document.getElementById("pause") resume_button = document.getElementById("resume") step_button = document.getElementById("step") restart_button = document.getElementById("restart") export_button = document.getElementById("export-replay") status_element = document.getElementById("status") board1_element = document.getElementById("board1") board2_element = document.getElementById("board2") bank_element = document.getElementById("bank") ai1_name_element = document.getElementById("ai1-name") ai2_name_element = document.getElementById("ai2-name") speed_slider = document.getElementById("speed") speed_value = document.getElementById("speed-value") ai1_strategy_select = document.getElementById("ai1-strategy") ai2_strategy_select = document.getElementById("ai2-strategy") canvas1 = document.getElementById("canvas1") canvas2 = document.getElementById("canvas2") bank_canvas = document.getElementById("bank-canvas") ctx1 = canvas1.getContext("2d") ctx2 = canvas2.getContext("2d") ctx_bank = bank_canvas.getContext("2d") view_ascii_button = document.getElementById("view-ascii") view_canvas_button = document.getElementById("view-canvas") stats_section = document.getElementById("stats-section") stats_grid = document.getElementById("stats-grid") thinking_section = document.getElementById("thinking-section") thinking_content = document.getElementById("thinking-content") show_thinking_checkbox = document.getElementById("show-thinking") commentary_feed = document.getElementById("commentary-feed") use_canvas_view = False def update_speed_label(event=None): speed_value.textContent = f"{speed_slider.value} ms" def format_thinking(ai_name, thinking_data): """Format AI thinking process into human-readable text.""" if not thinking_data: return "" piece_color = COLORS.get(thinking_data["piece"], "#39ff14") lines = [ '' + ai_name + ' received piece ' + thinking_data["piece"] + '', f'Evaluated {thinking_data["total_options"]} possible placements in {thinking_data["decision_time"]*1000:.1f}ms', "", '🏆 Top 5 Placements Considered:' ] for i, eval_data in enumerate(thinking_data["evaluations"][:5], 1): m = eval_data["metrics"] score = eval_data["score"] is_best = (eval_data["x"] == thinking_data["best"][0] and eval_data["rotation"] == thinking_data["best"][2]) prefix = "✓" if is_best else f"{i}." color = "#39ff14" if is_best else "#e2e2e2" font_weight = "bold" if is_best else "normal" placement_desc = f'x={eval_data["x"]}, rot={eval_data["rotation"]}' lines.append( '' + f'{prefix} {placement_desc}: score={score:.1f} ' + f'(h={m["height"]}, holes={m["holes"]}, bump={m["bumpiness"]}, lines={m["lines"]})' + '' ) lines.append("") lines.append( '➤ CHOSEN: ' + f'x={thinking_data["best"][0]}, rotation={thinking_data["best"][2]} ' + f'(score: {thinking_data["best_score"]:.1f})' ) return "
".join(lines) def display_thinking(ai, ai_name): """Display AI's thinking process if enabled.""" if not show_thinking_checkbox.checked: thinking_section.style.display = "none" return if hasattr(ai, "last_thinking") and ai.last_thinking: thinking_section.style.display = "block" thinking_content.innerHTML = format_thinking(ai_name, ai.last_thinking) else: thinking_section.style.display = "none" # --- Live Commentary System -------------------------------------------------- class Commentator: """Dynamic sports commentator for AI battles.""" def __init__(self): self.commentary_log = [] self.last_heights = {"ai1": 0, "ai2": 0} self.combo_count = {"ai1": 0, "ai2": 0} self.critical_moments = 0 def add_comment(self, text, style="normal"): """Add a commentary line with timestamp and styling.""" colors = { "normal": "#e2e2e2", "excited": "#39ff14", "critical": "#ff3b30", "achievement": "#f0a000", "dramatic": "#a000f0" } color = colors.get(style, "#e2e2e2") self.commentary_log.append('
' + text + '
') def clear(self): """Clear commentary log.""" self.commentary_log = [] self.last_heights = {"ai1": 0, "ai2": 0} self.combo_count = {"ai1": 0, "ai2": 0} self.critical_moments = 0 def get_html(self): """Get formatted HTML for display.""" return "".join(self.commentary_log[-8:]) # Show last 8 lines def commentate_move(self, ai_name, piece_type, board, lines_cleared, attack_piece): """Generate commentary for a single move.""" piece_color = COLORS.get(piece_type, "#39ff14") piece_html = '' + piece_type + '' ai_html = '' + ai_name + '' height = board.get_max_height() ai_key = "ai1" if "AI-1" in ai_name else "ai2" height_change = height - self.last_heights[ai_key] self.last_heights[ai_key] = height # Opening move if self.last_heights[ai_key] < 5: self.add_comment(f"{ai_html} opens with {piece_html}. Conservative start!") return # Tetris (4 lines)! if lines_cleared == 4: self.combo_count[ai_key] += 1 self.add_comment( f"🔥 TETRIS!!! {ai_html} clears FOUR LINES with {piece_html}! The crowd goes WILD!", "excited" ) if attack_piece: attack_color = COLORS.get(attack_piece, "#39ff14") self.add_comment( '💣 And sends ' + attack_piece + ' as a devastating counter-attack!', "achievement" ) return # Triple clear if lines_cleared == 3: self.add_comment( f"⚡ Triple clear by {ai_html}! {piece_html} placed perfectly!", "achievement" ) return # Double clear if lines_cleared == 2: self.combo_count[ai_key] += 1 if self.combo_count[ai_key] >= 3: self.add_comment(f"🎯 {ai_html} on a COMBO streak! Double clear!", "excited") else: self.add_comment(f"✓ {ai_html} clears 2 lines with {piece_html}.") return # Single clear if lines_cleared == 1: self.add_comment(f"{ai_html} clears 1 line. Steady progress...") self.combo_count[ai_key] = 0 return # Dangerous height if height >= 16: self.critical_moments += 1 if height >= 18: self.add_comment( f"🚨 CRITICAL! {ai_html}'s board at height {height}! One wrong move and it's GAME OVER!", "critical" ) else: self.add_comment( f"⚠️ {ai_html} in the danger zone! Height: {height}. Can they recover?", "dramatic" ) return # Height building (Aggressive behavior) if height_change > 3: self.add_comment( f"📈 {ai_html} building HIGH! {piece_html} placed at height {height}. Risky strategy!", "dramatic" ) return # Safe play (Defensive behavior) if height < 10 and lines_cleared == 0: if random.random() < 0.3: # Don't spam boring moves self.add_comment(f"{ai_html} playing it safe. {piece_html} carefully positioned.") return # Default move if random.random() < 0.2: # Occasional neutral commentary self.add_comment(f"{ai_html} places {piece_html}. Height: {height}.") def commentate_match_start(self, ai1_name, ai2_name): """Opening commentary.""" self.add_comment("🎙️ Ladies and gentlemen, welcome to the TETRIS ARENA!", "excited") self.add_comment(f"Today's matchup: {ai1_name} versus {ai2_name}!", "normal") self.add_comment("Let the battle... BEGIN! 🔥", "achievement") def commentate_match_end(self, winner, reason, ai1_score, ai2_score): """Closing commentary.""" if reason == "ai1_board_overflow": self.add_comment("💥 AI-1's board has COLLAPSED!", "critical") elif reason == "ai2_board_overflow": self.add_comment("💥 AI-2's board has COLLAPSED!", "critical") elif reason == "turn_limit": self.add_comment("⏰ Time's up! Let's see the final scores...", "dramatic") self.add_comment( f"🏆 WINNER: {winner}! Final score: AI-1: {ai1_score} | AI-2: {ai2_score}", "achievement" ) self.add_comment("What an INCREDIBLE match! Thanks for watching! 👏", "excited") commentator = Commentator() async def run_match(event=None): if controller.is_running: return controller.is_running = True controller.is_paused = False controller.update_buttons() ai1_strategy = ai1_strategy_select.value ai2_strategy = ai2_strategy_select.value bank = FigureBank(initial_count=12) ai1 = AIAgent(f"AI-1 ({ai1_strategy.capitalize()})", strategy=ai1_strategy) ai2 = AIAgent(f"AI-2 ({ai2_strategy.capitalize()})", strategy=ai2_strategy) controller.arena = Arena(ai1, ai2, bank, max_turns=320) ai1_name_element.textContent = ai1.name ai2_name_element.textContent = ai2.name status_element.textContent = "Match in progress..." # Initialize commentary commentator.clear() commentator.commentate_match_start(ai1.name, ai2.name) commentary_feed.innerHTML = commentator.get_html() while controller.is_running: if controller.is_paused: await asyncio.sleep(0.1) continue # Show thinking process if enabled show_thinking = show_thinking_checkbox.checked # Execute step with verbose mode state = controller.arena.step(verbose=show_thinking) # Generate commentary for both moves if state["move_data"]: move_ai1 = state["move_data"]["ai1"] if move_ai1: commentator.commentate_move( controller.arena.ai1.name, move_ai1["piece"], move_ai1["board"], move_ai1["lines"], move_ai1["attack"] ) move_ai2 = state["move_data"]["ai2"] if move_ai2: commentator.commentate_move( controller.arena.ai2.name, move_ai2["piece"], move_ai2["board"], move_ai2["lines"], move_ai2["attack"] ) # Update commentary display commentary_feed.innerHTML = commentator.get_html() # Display AI-1's thinking if show_thinking: display_thinking(controller.arena.ai1, controller.arena.ai1.name) await asyncio.sleep(max(int(speed_slider.value), 50) / 1000.0) controller.update_ui() controller.update_status(state) controller.collect_stats(state) # Display AI-2's thinking if show_thinking and not state["finished"]: display_thinking(controller.arena.ai2, controller.arena.ai2.name) await asyncio.sleep(max(int(speed_slider.value), 50) / 1000.0) if state["finished"]: # Final commentary commentator.commentate_match_end( state["winner"], state["reason"], state["scores"]["ai1"], state["scores"]["ai2"] ) commentary_feed.innerHTML = commentator.get_html() # Record prediction result prediction_league.record_result(state["winner"]) controller.is_running = False controller.update_buttons(finished=True) controller.display_stats() thinking_section.style.display = "none" export_commentary_button.disabled = False export_highlights_button.disabled = False break delay = max(int(speed_slider.value), 50) / 1000.0 await asyncio.sleep(delay) def pause_match(event=None): controller.is_paused = True controller.update_buttons(paused=True) def resume_match(event=None): controller.is_paused = False controller.update_buttons(paused=False) async def step_match(event=None): if not controller.is_paused or not controller.arena: return state = controller.arena.step() controller.update_ui() controller.update_status(state) if state["finished"]: controller.update_buttons(finished=True) def restart_match(event=None): controller.is_running = False controller.is_paused = False controller.arena = None controller.match_stats = { "turns": [], "ai1_scores": [], "ai2_scores": [], "ai1_lines": [], "ai2_lines": [], } controller.update_buttons() board1_element.textContent = "" board2_element.textContent = "" bank_element.textContent = "" stats_section.style.display = "none" export_commentary_button.disabled = True export_highlights_button.disabled = True prediction_league.reset_prediction() commentator.clear() commentary_feed.innerHTML = '
Waiting for match to start...
' status_element.textContent = "Press 'Start Match' to begin a new simulation." start_handler = create_proxy(lambda e: asyncio.ensure_future(run_match(e))) pause_handler = create_proxy(pause_match) resume_handler = create_proxy(resume_match) step_handler = create_proxy(lambda e: asyncio.ensure_future(step_match(e))) restart_handler = create_proxy(restart_match) speed_handler = create_proxy(update_speed_label) start_button.addEventListener("click", start_handler) pause_button.addEventListener("click", pause_handler) resume_button.addEventListener("click", resume_handler) step_button.addEventListener("click", step_handler) restart_button.addEventListener("click", restart_handler) speed_slider.addEventListener("input", speed_handler) controller.update_buttons() update_speed_label() # --- Tournament Mode --------------------------------------------------------- tournament_results = [] start_tournament_button = document.getElementById("start-tournament") clear_tournament_button = document.getElementById("clear-tournament") num_matches_input = document.getElementById("num-matches") tournament_results_div = document.getElementById("tournament-results") tournament_tbody = document.getElementById("tournament-tbody") tournament_summary = document.getElementById("tournament-summary") async def run_tournament(event=None): global tournament_results if controller.is_running: return num_matches = int(num_matches_input.value) if num_matches < 1 or num_matches > 50: status_element.textContent = "Please enter 1-50 matches" return start_tournament_button.disabled = True clear_tournament_button.disabled = True start_button.disabled = True status_element.textContent = f"Running tournament: 0/{num_matches} matches completed..." ai1_strategy = ai1_strategy_select.value ai2_strategy = ai2_strategy_select.value for match_num in range(1, num_matches + 1): bank = FigureBank(initial_count=12) ai1 = AIAgent(f"AI-1 ({ai1_strategy.capitalize()})", strategy=ai1_strategy) ai2 = AIAgent(f"AI-2 ({ai2_strategy.capitalize()})", strategy=ai2_strategy) arena = Arena(ai1, ai2, bank, max_turns=320) state = None while True: state = arena.step() if state["finished"]: break await asyncio.sleep(0.001) tournament_results.append({ "match": match_num, "winner": state["winner"], "ai1_score": state["scores"]["ai1"], "ai2_score": state["scores"]["ai2"], "turns": state["turn"], "reason": state["reason"] }) status_element.textContent = f"Running tournament: {match_num}/{num_matches} matches completed..." await asyncio.sleep(0.05) display_tournament_results() start_tournament_button.disabled = False clear_tournament_button.disabled = False start_button.disabled = False status_element.textContent = f"Tournament complete! {num_matches} matches finished." def display_tournament_results(): global tournament_results if not tournament_results: return tournament_tbody.innerHTML = "" for result in tournament_results: row = document.createElement("tr") row.innerHTML = f""" {result['match']} {result['winner']} {result['ai1_score']} {result['ai2_score']} {result['turns']} {result['reason']} """ tournament_tbody.appendChild(row) ai1_wins = sum(1 for r in tournament_results if r["winner"] == "AI-1") ai2_wins = sum(1 for r in tournament_results if r["winner"] == "AI-2") total = len(tournament_results) avg_ai1_score = sum(r["ai1_score"] for r in tournament_results) / total avg_ai2_score = sum(r["ai2_score"] for r in tournament_results) / total avg_turns = sum(r["turns"] for r in tournament_results) / total tournament_summary.innerHTML = f"""
AI-1 Wins: {ai1_wins}/{total} ({ai1_wins*100/total:.1f}%)
Avg Score: {avg_ai1_score:.0f}
AI-2 Wins: {ai2_wins}/{total} ({ai2_wins*100/total:.1f}%)
Avg Score: {avg_ai2_score:.0f}
Avg Turns: {avg_turns:.1f}
Total Matches: {total}
""" tournament_results_div.classList.add("visible") def clear_tournament_results(event=None): global tournament_results tournament_results = [] tournament_tbody.innerHTML = "" tournament_summary.innerHTML = "" tournament_results_div.classList.remove("visible") status_element.textContent = "Tournament results cleared." tournament_handler = create_proxy(lambda e: asyncio.ensure_future(run_tournament(e))) clear_handler = create_proxy(clear_tournament_results) start_tournament_button.addEventListener("click", tournament_handler) clear_tournament_button.addEventListener("click", clear_handler) # --- View Toggle ------------------------------------------------------------- def switch_to_ascii(event=None): global use_canvas_view use_canvas_view = False canvas1.style.display = "none" canvas2.style.display = "none" bank_canvas.style.display = "none" board1.style.display = "block" board2.style.display = "block" bank.style.display = "block" view_ascii_button.classList.add("secondary") view_canvas_button.classList.remove("secondary") if controller.arena: controller.update_ui() def switch_to_canvas(event=None): global use_canvas_view use_canvas_view = True canvas1.style.display = "block" canvas2.style.display = "block" bank_canvas.style.display = "block" board1.style.display = "none" board2.style.display = "none" bank.style.display = "none" view_ascii_button.classList.remove("secondary") view_canvas_button.classList.add("secondary") if controller.arena: controller.update_ui() ascii_handler = create_proxy(switch_to_ascii) canvas_handler = create_proxy(switch_to_canvas) view_ascii_button.addEventListener("click", ascii_handler) view_canvas_button.addEventListener("click", canvas_handler) # --- Replay Export ----------------------------------------------------------- def export_replay(event=None): if not controller.arena or not controller.match_stats["turns"]: return from js import Date timestamp = Date.new().toISOString() replay_data = { "version": "1.0", "timestamp": timestamp, "match_info": { "ai1_name": controller.arena.ai1.name, "ai2_name": controller.arena.ai2.name, "ai1_strategy": controller.arena.ai1.strategy, "ai2_strategy": controller.arena.ai2.strategy, "max_turns": controller.arena.max_turns, }, "final_state": { "winner": "AI-1" if controller.arena.board1.score > controller.arena.board2.score else "AI-2", "ai1_score": controller.arena.board1.score, "ai2_score": controller.arena.board2.score, "ai1_lines": controller.arena.board1.lines_cleared, "ai2_lines": controller.arena.board2.lines_cleared, "total_turns": controller.arena.turn, "bank_remaining": controller.arena.bank.get_total_remaining(), }, "statistics": { "ai1_avg_decision_time_ms": controller.arena.ai1.get_average_decision_time() * 1000, "ai2_avg_decision_time_ms": controller.arena.ai2.get_average_decision_time() * 1000, "ai1_total_decisions": len(controller.arena.ai1.decision_times), "ai2_total_decisions": len(controller.arena.ai2.decision_times), }, "history": { "turns": controller.match_stats["turns"], "ai1_scores": controller.match_stats["ai1_scores"], "ai2_scores": controller.match_stats["ai2_scores"], "ai1_lines": controller.match_stats["ai1_lines"], "ai2_lines": controller.match_stats["ai2_lines"], } } from js import JSON, Blob, URL, document as doc json_str = JSON.stringify(replay_data, None, 2) blob = Blob.new([json_str], {"type": "application/json"}) url = URL.createObjectURL(blob) link = doc.createElement("a") link.href = url link.download = f"tetris_replay_{timestamp.replace(':', '-').replace('.', '_')}.json" link.click() URL.revokeObjectURL(url) status_element.textContent = "Replay exported successfully!" export_handler = create_proxy(export_replay) export_button.addEventListener("click", export_handler) # --- Commentary Export for TTS ----------------------------------------------- def export_commentary_for_tts(event=None): """Export commentary as clean text file for Text-to-Speech services (ElevenLabs, etc.)""" if not commentator.commentary_log: status_element.textContent = "No commentary to export. Run a match first!" return # Remove HTML tags and format for TTS import re clean_lines = [] for line in commentator.commentary_log: # Strip HTML tags clean_text = re.sub(r'<[^>]+>', '', line) # Replace HTML entities clean_text = clean_text.replace(' ', ' ') # Remove emoji and special characters that TTS might struggle with # (Keep basic punctuation for natural pauses) if clean_text.strip(): clean_lines.append(clean_text.strip()) # Add timing hints for better TTS delivery tts_script = "# AI Tetris Battle - Commentary Script\n" tts_script += "# Use this with ElevenLabs or similar TTS services\n" tts_script += "# Recommended voice: Energetic, Sports Announcer style\n\n" for i, line in enumerate(clean_lines): # Add pause hints after exciting moments if "TETRIS" in line.upper() or "CRITICAL" in line.upper(): tts_script += f"{line}...\n" # Extra pause else: tts_script += f"{line}\n" from js import Blob, URL, document as doc, Date timestamp = Date.new().toISOString() blob = Blob.new([tts_script], {"type": "text/plain"}) url = URL.createObjectURL(blob) link = doc.createElement("a") link.href = url link.download = f"commentary_script_{timestamp.replace(':', '-').replace('.', '_')}.txt" link.click() URL.revokeObjectURL(url) status_element.textContent = f"Commentary exported! {len(clean_lines)} lines ready for TTS." # --- Highlights Detection & Export ------------------------------------------- def export_highlights(event=None): """Export timestamp markers for highlight moments (TETRIS, critical situations, etc.)""" if not controller.arena or not controller.match_stats["turns"]: status_element.textContent = "No match data to analyze. Complete a match first!" return highlights = [] # Analyze match history for highlight moments for i, turn in enumerate(controller.match_stats["turns"]): ai1_lines = controller.match_stats["ai1_lines"][i] if i < len(controller.match_stats["ai1_lines"]) else 0 ai2_lines = controller.match_stats["ai2_lines"][i] if i < len(controller.match_stats["ai2_lines"]) else 0 # TETRIS (4 lines) if ai1_lines == 4: highlights.append({ "turn": turn, "type": "TETRIS", "player": "AI-1", "description": "AI-1 clears 4 lines (TETRIS!)", "importance": "HIGH" }) if ai2_lines == 4: highlights.append({ "turn": turn, "type": "TETRIS", "player": "AI-2", "description": "AI-2 clears 4 lines (TETRIS!)", "importance": "HIGH" }) # Triple clears if ai1_lines == 3: highlights.append({ "turn": turn, "type": "TRIPLE", "player": "AI-1", "description": "AI-1 clears 3 lines", "importance": "MEDIUM" }) if ai2_lines == 3: highlights.append({ "turn": turn, "type": "TRIPLE", "player": "AI-2", "description": "AI-2 clears 3 lines", "importance": "MEDIUM" }) # Add match end as highlight if controller.arena: highlights.append({ "turn": controller.arena.turn, "type": "MATCH_END", "player": "BOTH", "description": f"Match ends - Final scores: AI-1: {controller.arena.board1.score}, AI-2: {controller.arena.board2.score}", "importance": "HIGH" }) from js import JSON, Blob, URL, document as doc, Date timestamp = Date.new().toISOString() highlights_data = { "version": "1.0", "timestamp": timestamp, "match_info": { "ai1": controller.arena.ai1.name, "ai2": controller.arena.ai2.name, }, "total_highlights": len(highlights), "highlights": highlights, "usage": "Use these timestamps to create YouTube chapters or Shorts clips" } json_str = JSON.stringify(highlights_data, None, 2) blob = Blob.new([json_str], {"type": "application/json"}) url = URL.createObjectURL(blob) link = doc.createElement("a") link.href = url link.download = f"highlights_{timestamp.replace(':', '-').replace('.', '_')}.json" link.click() URL.revokeObjectURL(url) status_element.textContent = f"Highlights exported! Found {len(highlights)} key moments for editing." # --- Prediction League System ------------------------------------------------ class PredictionLeague: def __init__(self): self.user_stats = { "total": 0, "correct": 0, "predictions": [] } self.current_prediction = None self.match_history = [] self.load_from_storage() def load_from_storage(self): """Load stats from localStorage if available""" try: from js import localStorage if localStorage.getItem("prediction_stats"): import json data = json.loads(localStorage.getItem("prediction_stats")) self.user_stats = data.get("user_stats", self.user_stats) self.match_history = data.get("match_history", []) except: pass def save_to_storage(self): """Save stats to localStorage""" try: from js import localStorage, JSON import json data = { "user_stats": self.user_stats, "match_history": self.match_history[-50:] # Keep last 50 matches } localStorage.setItem("prediction_stats", json.dumps(data)) except: pass def make_prediction(self, ai_name): """User makes a prediction""" self.current_prediction = ai_name document.getElementById("current-prediction").textContent = ai_name document.getElementById("predict-ai1").disabled = True document.getElementById("predict-ai2").disabled = True status_element.textContent = f"Prediction locked: {ai_name} will win!" def record_result(self, winner): """Record match result and update stats""" if not self.current_prediction: return is_correct = (self.current_prediction == winner) self.user_stats["total"] += 1 if is_correct: self.user_stats["correct"] += 1 self.user_stats["predictions"].append({ "prediction": self.current_prediction, "actual": winner, "correct": is_correct }) self.match_history.append({ "prediction": self.current_prediction, "winner": winner, "correct": is_correct }) self.save_to_storage() self.update_ui() # Reset for next match self.current_prediction = None document.getElementById("current-prediction").textContent = "None" document.getElementById("predict-ai1").disabled = False document.getElementById("predict-ai2").disabled = False # Show result feedback if is_correct: status_element.textContent = "🎉 Correct prediction! Your accuracy improved!" else: status_element.textContent = "❌ Wrong prediction. Better luck next time!" def update_ui(self): """Update UI with current stats""" total = self.user_stats["total"] correct = self.user_stats["correct"] accuracy = (correct / total * 100) if total > 0 else 0 document.getElementById("user-total-predictions").textContent = str(total) document.getElementById("user-correct-predictions").textContent = str(correct) document.getElementById("user-accuracy").textContent = f"{accuracy:.1f}%" def calculate_odds(self, ai1_name, ai2_name): """Calculate historical odds based on past matches""" if len(self.match_history) < 3: return "AI-1: 50% | AI-2: 50% (based on 0 past matches)" ai1_wins = sum(1 for m in self.match_history if ai1_name in m["winner"]) ai2_wins = sum(1 for m in self.match_history if ai2_name in m["winner"]) total = ai1_wins + ai2_wins if total == 0: return f"AI-1: 50% | AI-2: 50% (based on {len(self.match_history)} past matches)" ai1_pct = (ai1_wins / total * 100) ai2_pct = (ai2_wins / total * 100) return f"AI-1: {ai1_pct:.0f}% | AI-2: {ai2_pct:.0f}% (based on {total} past matches)" def reset_prediction(self): """Reset current prediction without recording""" self.current_prediction = None document.getElementById("current-prediction").textContent = "None" document.getElementById("predict-ai1").disabled = False document.getElementById("predict-ai2").disabled = False prediction_league = PredictionLeague() prediction_league.update_ui() def predict_ai1(event=None): ai1_name = document.getElementById("ai1-name").textContent or "AI-1" prediction_league.make_prediction(ai1_name) def predict_ai2(event=None): ai2_name = document.getElementById("ai2-name").textContent or "AI-2" prediction_league.make_prediction(ai2_name) # Setup event listeners export_commentary_button = document.getElementById("export-commentary") export_highlights_button = document.getElementById("export-highlights") predict_ai1_button = document.getElementById("predict-ai1") predict_ai2_button = document.getElementById("predict-ai2") export_commentary_handler = create_proxy(export_commentary_for_tts) export_highlights_handler = create_proxy(export_highlights) predict_ai1_handler = create_proxy(predict_ai1) predict_ai2_handler = create_proxy(predict_ai2) export_commentary_button.addEventListener("click", export_commentary_handler) export_highlights_button.addEventListener("click", export_highlights_handler) predict_ai1_button.addEventListener("click", predict_ai1_handler) predict_ai2_button.addEventListener("click", predict_ai2_handler) # Update prediction odds when strategies change def update_prediction_odds(): ai1_name = ai1_strategy_select.value ai2_name = ai2_strategy_select.value odds_text = prediction_league.calculate_odds(f"AI-1 ({ai1_name.capitalize()})", f"AI-2 ({ai2_name.capitalize()})") document.getElementById("prediction-odds").textContent = f"Historical odds: {odds_text}" # Update button labels document.getElementById("predict-ai1-name").textContent = f"AI-1 ({ai1_name.capitalize()})" document.getElementById("predict-ai2-name").textContent = f"AI-2 ({ai2_name.capitalize()})" strategy_change_handler = create_proxy(update_prediction_odds) ai1_strategy_select.addEventListener("change", strategy_change_handler) ai2_strategy_select.addEventListener("change", strategy_change_handler) # Initialize odds on load update_prediction_odds()