# BalatroBot > A bot framework for Balatro BalatroBot is a Python framework for developing automated bots to play the card game Balatro. The architecture consists of three main layers: a communication layer using TCP protocol with Lua API, a Python framework layer for bot development, and comprehensive testing and documentation systems. The project enables real-time bidirectional communication between the game and bot through TCP sockets. # Documentation # BalatroBot API This page provides comprehensive API documentation for the BalatroBot Python framework. The API enables you to build automated bots that interact with the Balatro card game through a structured TCP communication protocol. The API is organized into several key components: the `BalatroClient` for managing game connections and sending commands, enums that define game states and actions, exception classes for robust error handling, and data models that structure requests and responses between your bot and the game. ## Client The `BalatroClient` is the main interface for communicating with the Balatro game through TCP connections. It handles connection management, message serialization, and error handling. ### `balatrobot.client.BalatroClient` Client for communicating with the BalatroBot game API. The client provides methods for game control, state management, and development tools including a checkpointing system for saving and loading game states. Attributes: | Name | Type | Description | | ------------- | -------- | --------------------------- | | `host` | | Host address to connect to | | `port` | | Port number to connect to | | `timeout` | | Socket timeout in seconds | | `buffer_size` | | Socket buffer size in bytes | | `_socket` | \`socket | None\` | Source code in `src/balatrobot/client.py` ````python class BalatroClient: """Client for communicating with the BalatroBot game API. The client provides methods for game control, state management, and development tools including a checkpointing system for saving and loading game states. Attributes: host: Host address to connect to port: Port number to connect to timeout: Socket timeout in seconds buffer_size: Socket buffer size in bytes _socket: Socket connection to BalatroBot """ host = "127.0.0.1" timeout = 300.0 buffer_size = 65536 def __init__(self, port: int = 12346, timeout: float | None = None): """Initialize BalatroBot client Args: port: Port number to connect to (default: 12346) timeout: Socket timeout in seconds (default: 300.0) """ self.port = port self.timeout = timeout if timeout is not None else self.timeout self._socket: socket.socket | None = None self._connected = False self._message_buffer = b"" # Buffer for incomplete messages def _receive_complete_message(self) -> bytes: """Receive a complete message from the socket, handling message boundaries properly.""" if not self._connected or not self._socket: raise ConnectionFailedError( "Socket not connected", error_code="E008", context={ "connected": self._connected, "socket": self._socket is not None, }, ) # Check if we already have a complete message in the buffer while b"\n" not in self._message_buffer: try: chunk = self._socket.recv(self.buffer_size) except socket.timeout: raise ConnectionFailedError( "Socket timeout while receiving data", error_code="E008", context={ "timeout": self.timeout, "buffer_size": len(self._message_buffer), }, ) except socket.error as e: raise ConnectionFailedError( f"Socket error while receiving: {e}", error_code="E008", context={"error": str(e), "buffer_size": len(self._message_buffer)}, ) if not chunk: raise ConnectionFailedError( "Connection closed by server", error_code="E008", context={"buffer_size": len(self._message_buffer)}, ) self._message_buffer += chunk # Extract the first complete message message_end = self._message_buffer.find(b"\n") complete_message = self._message_buffer[:message_end] # Update buffer to remove the processed message remaining_data = self._message_buffer[message_end + 1 :] self._message_buffer = remaining_data # Log any remaining data for debugging if remaining_data: logger.warning(f"Data remaining in buffer: {len(remaining_data)} bytes") logger.debug(f"Buffer preview: {remaining_data[:100]}...") return complete_message def __enter__(self) -> Self: """Enter context manager and connect to the game.""" self.connect() return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: """Exit context manager and disconnect from the game.""" self.disconnect() def connect(self) -> None: """Connect to Balatro TCP server Raises: ConnectionFailedError: If not connected to the game """ if self._connected: return logger.info(f"Connecting to BalatroBot API at {self.host}:{self.port}") try: self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.settimeout(self.timeout) self._socket.setsockopt( socket.SOL_SOCKET, socket.SO_RCVBUF, self.buffer_size ) self._socket.connect((self.host, self.port)) self._connected = True logger.info( f"Successfully connected to BalatroBot API at {self.host}:{self.port}" ) except (socket.error, OSError) as e: logger.error(f"Failed to connect to {self.host}:{self.port}: {e}") raise ConnectionFailedError( f"Failed to connect to {self.host}:{self.port}", error_code="E008", context={"host": self.host, "port": self.port, "error": str(e)}, ) from e def disconnect(self) -> None: """Disconnect from the BalatroBot game API.""" if self._socket: logger.info(f"Disconnecting from BalatroBot API at {self.host}:{self.port}") self._socket.close() self._socket = None self._connected = False # Clear message buffer on disconnect self._message_buffer = b"" def send_message(self, name: str, arguments: dict | None = None) -> dict: """Send JSON message to Balatro and receive response Args: name: Function name to call arguments: Function arguments Returns: Response from the game API Raises: ConnectionFailedError: If not connected to the game BalatroError: If the API returns an error """ if arguments is None: arguments = {} if not self._connected or not self._socket: raise ConnectionFailedError( "Not connected to the game API", error_code="E008", context={ "connected": self._connected, "socket": self._socket is not None, }, ) # Create and validate request request = APIRequest(name=name, arguments=arguments) logger.debug(f"Sending API request: {name}") try: # Send request message = request.model_dump_json() + "\n" self._socket.send(message.encode()) # Receive response using improved message handling complete_message = self._receive_complete_message() # Decode and validate the message message_str = complete_message.decode().strip() logger.debug(f"Raw message length: {len(message_str)} characters") logger.debug(f"Message preview: {message_str[:100]}...") # Ensure the message is properly formatted JSON if not message_str: raise BalatroError( "Empty response received from game", error_code="E001", context={"raw_data_length": len(complete_message)}, ) response_data = json.loads(message_str) # Check for error response if "error" in response_data: logger.error(f"API request {name} failed: {response_data.get('error')}") raise create_exception_from_error_response(response_data) logger.debug(f"API request {name} completed successfully") return response_data except socket.error as e: logger.error(f"Socket error during API request {name}: {e}") raise ConnectionFailedError( f"Socket error during communication: {e}", error_code="E008", context={"error": str(e)}, ) from e except json.JSONDecodeError as e: logger.error(f"Invalid JSON response from API request {name}: {e}") logger.error(f"Problematic message content: {message_str[:200]}...") logger.error( f"Message buffer state: {len(self._message_buffer)} bytes remaining" ) # Clear the message buffer to prevent cascading errors if self._message_buffer: logger.warning("Clearing message buffer due to JSON parse error") self._message_buffer = b"" raise BalatroError( f"Invalid JSON response from game: {e}", error_code="E001", context={"error": str(e), "message_preview": message_str[:100]}, ) from e # Checkpoint Management Methods def _convert_windows_path_to_linux(self, windows_path: str) -> str: """Convert Windows path to Linux Steam Proton path if on Linux. Args: windows_path: Windows-style path (e.g., "C:/Users/.../Balatro/3/save.jkr") Returns: Converted path for Linux or original path for other platforms """ if platform.system() == "Linux": # Match Windows drive letter and path (e.g., "C:/...", "D:\\...", "E:...") match = re.match(r"^([A-Z]):[\\/]*(.*)", windows_path, re.IGNORECASE) if match: # Replace drive letter with Linux Steam Proton prefix linux_prefix = str( Path( "~/.steam/steam/steamapps/compatdata/2379780/pfx/drive_c" ).expanduser() ) # Normalize slashes and join with prefix rest_of_path = match.group(2).replace("\\", "/") return linux_prefix + "/" + rest_of_path return windows_path def get_save_info(self) -> dict: """Get the current save file location and profile information. Development tool for working with save files and checkpoints. Returns: Dictionary containing: - profile_path: Current profile path (e.g., "3") - save_directory: Full path to Love2D save directory - save_file_path: Full OS-specific path to save.jkr file - has_active_run: Whether a run is currently active - save_exists: Whether the save file exists Raises: BalatroError: If request fails Note: This is primarily for development and testing purposes. """ save_info = self.send_message("get_save_info") # Convert Windows paths to Linux Steam Proton paths if needed if "save_file_path" in save_info and save_info["save_file_path"]: save_info["save_file_path"] = self._convert_windows_path_to_linux( save_info["save_file_path"] ) if "save_directory" in save_info and save_info["save_directory"]: save_info["save_directory"] = self._convert_windows_path_to_linux( save_info["save_directory"] ) return save_info def save_checkpoint(self, checkpoint_name: str | Path) -> Path: """Save the current save.jkr file as a checkpoint. Args: checkpoint_name: Either: - A checkpoint name (saved to checkpoints dir) - A full file path where the checkpoint should be saved - A directory path (checkpoint will be saved as 'save.jkr' inside it) Returns: Path to the saved checkpoint file Raises: BalatroError: If no save file exists or the destination path is invalid IOError: If file operations fail """ # Get current save info save_info = self.get_save_info() if not save_info.get("save_exists"): raise BalatroError( "No save file exists to checkpoint", ErrorCode.INVALID_GAME_STATE ) # Get the full save file path from API (already OS-specific) save_path = Path(save_info["save_file_path"]) if not save_path.exists(): raise BalatroError( f"Save file not found: {save_path}", ErrorCode.MISSING_GAME_OBJECT ) # Normalize and interpret destination dest = Path(checkpoint_name).expanduser() # Treat paths without a .jkr suffix as directories if dest.suffix.lower() != ".jkr": raise BalatroError( f"Invalid checkpoint path provided: {dest}", ErrorCode.INVALID_PARAMETER, context={"path": str(dest), "reason": "Path does not end with .jkr"}, ) # Ensure destination directory exists try: dest.parent.mkdir(parents=True, exist_ok=True) except OSError as e: raise BalatroError( f"Invalid checkpoint path provided: {dest}", ErrorCode.INVALID_PARAMETER, context={"path": str(dest), "reason": str(e)}, ) from e # Copy save file to checkpoint try: shutil.copy2(save_path, dest) except OSError as e: raise BalatroError( f"Failed to write checkpoint to: {dest}", ErrorCode.INVALID_PARAMETER, context={"path": str(dest), "reason": str(e)}, ) from e return dest def prepare_save(self, source_path: str | Path) -> str: """Prepare a test save file for use with load_save. This copies a .jkr file from your test directory into Love2D's save directory in a temporary profile so it can be loaded with load_save(). Args: source_path: Path to the .jkr save file to prepare Returns: The Love2D-relative path to use with load_save() (e.g., "checkpoint/save.jkr") Raises: BalatroError: If source file not found IOError: If file operations fail """ source = Path(source_path) if not source.exists(): raise BalatroError( f"Source save file not found: {source}", ErrorCode.MISSING_GAME_OBJECT ) # Get save directory info save_info = self.get_save_info() if not save_info.get("save_directory"): raise BalatroError( "Cannot determine Love2D save directory", ErrorCode.INVALID_GAME_STATE ) checkpoints_profile = "checkpoint" save_dir = Path(save_info["save_directory"]) checkpoints_dir = save_dir / checkpoints_profile checkpoints_dir.mkdir(parents=True, exist_ok=True) # Copy the save file to the test profile dest_path = checkpoints_dir / "save.jkr" shutil.copy2(source, dest_path) # Return the Love2D-relative path return f"{checkpoints_profile}/save.jkr" def load_save(self, save_path: str | Path) -> dict: """Load a save file directly without requiring a game restart. This method loads a save file (in Love2D's save directory format) and starts a run from that save state. Unlike load_checkpoint which copies to the profile's save location and requires restart, this directly loads the save into the game. This is particularly useful for testing as it allows you to quickly jump to specific game states without manual setup. Args: save_path: Path to the save file relative to Love2D save directory (e.g., "3/save.jkr" for profile 3's save) Returns: Game state after loading the save Raises: BalatroError: If save file not found or loading fails Note: This is a development tool that bypasses normal game flow. Use with caution in production bots. Example: ```python # Load a profile's save directly game_state = client.load_save("3/save.jkr") # Or use with prepare_save for external files save_path = client.prepare_save("tests/fixtures/shop_state.jkr") game_state = client.load_save(save_path) ``` """ # Convert to string if Path object if isinstance(save_path, Path): save_path = str(save_path) # Send load_save request to API return self.send_message("load_save", {"save_path": save_path}) def load_absolute_save(self, save_path: str | Path) -> dict: """Load a save from an absolute path. Takes a full path from the OS as a .jkr file and loads it into the game. Args: save_path: Path to the save file relative to Love2D save directory (e.g., "3/save.jkr" for profile 3's save) Returns: Game state after loading the save """ love_save_path = self.prepare_save(save_path) return self.load_save(love_save_path) def screenshot(self, path: Path | None = None) -> Path: """ Take a screenshot and save as both PNG and JPEG formats. Args: path: Optional path for PNG file. If provided, PNG will be moved to this location. Returns: Path to the PNG screenshot. JPEG is saved alongside with .jpg extension. Note: The response now includes both 'path' (PNG) and 'jpeg_path' (JPEG) keys. This method maintains backward compatibility by returning the PNG path. """ screenshot_response = self.send_message("screenshot", {}) if path is None: return Path(screenshot_response["path"]) else: source_path = Path(screenshot_response["path"]) dest_path = path shutil.move(source_path, dest_path) return dest_path ```` #### `connect()` Connect to Balatro TCP server Raises: | Type | Description | | ----------------------- | ---------------------------- | | `ConnectionFailedError` | If not connected to the game | Source code in `src/balatrobot/client.py` ```python def connect(self) -> None: """Connect to Balatro TCP server Raises: ConnectionFailedError: If not connected to the game """ if self._connected: return logger.info(f"Connecting to BalatroBot API at {self.host}:{self.port}") try: self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.settimeout(self.timeout) self._socket.setsockopt( socket.SOL_SOCKET, socket.SO_RCVBUF, self.buffer_size ) self._socket.connect((self.host, self.port)) self._connected = True logger.info( f"Successfully connected to BalatroBot API at {self.host}:{self.port}" ) except (socket.error, OSError) as e: logger.error(f"Failed to connect to {self.host}:{self.port}: {e}") raise ConnectionFailedError( f"Failed to connect to {self.host}:{self.port}", error_code="E008", context={"host": self.host, "port": self.port, "error": str(e)}, ) from e ``` #### `disconnect()` Disconnect from the BalatroBot game API. Source code in `src/balatrobot/client.py` ```python def disconnect(self) -> None: """Disconnect from the BalatroBot game API.""" if self._socket: logger.info(f"Disconnecting from BalatroBot API at {self.host}:{self.port}") self._socket.close() self._socket = None self._connected = False # Clear message buffer on disconnect self._message_buffer = b"" ``` #### `get_save_info()` Get the current save file location and profile information. Development tool for working with save files and checkpoints. Returns: | Type | Description | | ------ | ------------------------------------------------------ | | `dict` | Dictionary containing: | | `dict` | profile_path: Current profile path (e.g., "3") | | `dict` | save_directory: Full path to Love2D save directory | | `dict` | save_file_path: Full OS-specific path to save.jkr file | | `dict` | has_active_run: Whether a run is currently active | | `dict` | save_exists: Whether the save file exists | Raises: | Type | Description | | -------------- | ---------------- | | `BalatroError` | If request fails | Note This is primarily for development and testing purposes. Source code in `src/balatrobot/client.py` ```python def get_save_info(self) -> dict: """Get the current save file location and profile information. Development tool for working with save files and checkpoints. Returns: Dictionary containing: - profile_path: Current profile path (e.g., "3") - save_directory: Full path to Love2D save directory - save_file_path: Full OS-specific path to save.jkr file - has_active_run: Whether a run is currently active - save_exists: Whether the save file exists Raises: BalatroError: If request fails Note: This is primarily for development and testing purposes. """ save_info = self.send_message("get_save_info") # Convert Windows paths to Linux Steam Proton paths if needed if "save_file_path" in save_info and save_info["save_file_path"]: save_info["save_file_path"] = self._convert_windows_path_to_linux( save_info["save_file_path"] ) if "save_directory" in save_info and save_info["save_directory"]: save_info["save_directory"] = self._convert_windows_path_to_linux( save_info["save_directory"] ) return save_info ``` #### `load_absolute_save(save_path)` Load a save from an absolute path. Takes a full path from the OS as a .jkr file and loads it into the game. Parameters: | Name | Type | Description | Default | | ----------- | ----- | ----------- | ------------------------------------------------------------------------------------------------- | | `save_path` | \`str | Path\` | Path to the save file relative to Love2D save directory (e.g., "3/save.jkr" for profile 3's save) | Returns: | Type | Description | | ------ | --------------------------------- | | `dict` | Game state after loading the save | Source code in `src/balatrobot/client.py` ```python def load_absolute_save(self, save_path: str | Path) -> dict: """Load a save from an absolute path. Takes a full path from the OS as a .jkr file and loads it into the game. Args: save_path: Path to the save file relative to Love2D save directory (e.g., "3/save.jkr" for profile 3's save) Returns: Game state after loading the save """ love_save_path = self.prepare_save(save_path) return self.load_save(love_save_path) ``` #### `load_save(save_path)` Load a save file directly without requiring a game restart. This method loads a save file (in Love2D's save directory format) and starts a run from that save state. Unlike load_checkpoint which copies to the profile's save location and requires restart, this directly loads the save into the game. This is particularly useful for testing as it allows you to quickly jump to specific game states without manual setup. Parameters: | Name | Type | Description | Default | | ----------- | ----- | ----------- | ------------------------------------------------------------------------------------------------- | | `save_path` | \`str | Path\` | Path to the save file relative to Love2D save directory (e.g., "3/save.jkr" for profile 3's save) | Returns: | Type | Description | | ------ | --------------------------------- | | `dict` | Game state after loading the save | Raises: | Type | Description | | -------------- | --------------------------------------- | | `BalatroError` | If save file not found or loading fails | Note This is a development tool that bypasses normal game flow. Use with caution in production bots. Example ```python # Load a profile's save directly game_state = client.load_save("3/save.jkr") # Or use with prepare_save for external files save_path = client.prepare_save("tests/fixtures/shop_state.jkr") game_state = client.load_save(save_path) ``` Source code in `src/balatrobot/client.py` ````python def load_save(self, save_path: str | Path) -> dict: """Load a save file directly without requiring a game restart. This method loads a save file (in Love2D's save directory format) and starts a run from that save state. Unlike load_checkpoint which copies to the profile's save location and requires restart, this directly loads the save into the game. This is particularly useful for testing as it allows you to quickly jump to specific game states without manual setup. Args: save_path: Path to the save file relative to Love2D save directory (e.g., "3/save.jkr" for profile 3's save) Returns: Game state after loading the save Raises: BalatroError: If save file not found or loading fails Note: This is a development tool that bypasses normal game flow. Use with caution in production bots. Example: ```python # Load a profile's save directly game_state = client.load_save("3/save.jkr") # Or use with prepare_save for external files save_path = client.prepare_save("tests/fixtures/shop_state.jkr") game_state = client.load_save(save_path) ``` """ # Convert to string if Path object if isinstance(save_path, Path): save_path = str(save_path) # Send load_save request to API return self.send_message("load_save", {"save_path": save_path}) ```` #### `prepare_save(source_path)` Prepare a test save file for use with load_save. This copies a .jkr file from your test directory into Love2D's save directory in a temporary profile so it can be loaded with load_save(). Parameters: | Name | Type | Description | Default | | ------------- | ----- | ----------- | ------------------------------------- | | `source_path` | \`str | Path\` | Path to the .jkr save file to prepare | Returns: | Type | Description | | ----- | ------------------------------------------------ | | `str` | The Love2D-relative path to use with load_save() | | `str` | (e.g., "checkpoint/save.jkr") | Raises: | Type | Description | | -------------- | ------------------------ | | `BalatroError` | If source file not found | | `IOError` | If file operations fail | Source code in `src/balatrobot/client.py` ```python def prepare_save(self, source_path: str | Path) -> str: """Prepare a test save file for use with load_save. This copies a .jkr file from your test directory into Love2D's save directory in a temporary profile so it can be loaded with load_save(). Args: source_path: Path to the .jkr save file to prepare Returns: The Love2D-relative path to use with load_save() (e.g., "checkpoint/save.jkr") Raises: BalatroError: If source file not found IOError: If file operations fail """ source = Path(source_path) if not source.exists(): raise BalatroError( f"Source save file not found: {source}", ErrorCode.MISSING_GAME_OBJECT ) # Get save directory info save_info = self.get_save_info() if not save_info.get("save_directory"): raise BalatroError( "Cannot determine Love2D save directory", ErrorCode.INVALID_GAME_STATE ) checkpoints_profile = "checkpoint" save_dir = Path(save_info["save_directory"]) checkpoints_dir = save_dir / checkpoints_profile checkpoints_dir.mkdir(parents=True, exist_ok=True) # Copy the save file to the test profile dest_path = checkpoints_dir / "save.jkr" shutil.copy2(source, dest_path) # Return the Love2D-relative path return f"{checkpoints_profile}/save.jkr" ``` #### `save_checkpoint(checkpoint_name)` Save the current save.jkr file as a checkpoint. Parameters: | Name | Type | Description | Default | | ----------------- | ----- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `checkpoint_name` | \`str | Path\` | Either: - A checkpoint name (saved to checkpoints dir) - A full file path where the checkpoint should be saved - A directory path (checkpoint will be saved as 'save.jkr' inside it) | Returns: | Type | Description | | ------ | --------------------------------- | | `Path` | Path to the saved checkpoint file | Raises: | Type | Description | | -------------- | --------------------------------------------------------- | | `BalatroError` | If no save file exists or the destination path is invalid | | `IOError` | If file operations fail | Source code in `src/balatrobot/client.py` ```python def save_checkpoint(self, checkpoint_name: str | Path) -> Path: """Save the current save.jkr file as a checkpoint. Args: checkpoint_name: Either: - A checkpoint name (saved to checkpoints dir) - A full file path where the checkpoint should be saved - A directory path (checkpoint will be saved as 'save.jkr' inside it) Returns: Path to the saved checkpoint file Raises: BalatroError: If no save file exists or the destination path is invalid IOError: If file operations fail """ # Get current save info save_info = self.get_save_info() if not save_info.get("save_exists"): raise BalatroError( "No save file exists to checkpoint", ErrorCode.INVALID_GAME_STATE ) # Get the full save file path from API (already OS-specific) save_path = Path(save_info["save_file_path"]) if not save_path.exists(): raise BalatroError( f"Save file not found: {save_path}", ErrorCode.MISSING_GAME_OBJECT ) # Normalize and interpret destination dest = Path(checkpoint_name).expanduser() # Treat paths without a .jkr suffix as directories if dest.suffix.lower() != ".jkr": raise BalatroError( f"Invalid checkpoint path provided: {dest}", ErrorCode.INVALID_PARAMETER, context={"path": str(dest), "reason": "Path does not end with .jkr"}, ) # Ensure destination directory exists try: dest.parent.mkdir(parents=True, exist_ok=True) except OSError as e: raise BalatroError( f"Invalid checkpoint path provided: {dest}", ErrorCode.INVALID_PARAMETER, context={"path": str(dest), "reason": str(e)}, ) from e # Copy save file to checkpoint try: shutil.copy2(save_path, dest) except OSError as e: raise BalatroError( f"Failed to write checkpoint to: {dest}", ErrorCode.INVALID_PARAMETER, context={"path": str(dest), "reason": str(e)}, ) from e return dest ``` #### `screenshot(path=None)` Take a screenshot and save as both PNG and JPEG formats. Parameters: | Name | Type | Description | Default | | ------ | ------ | ----------- | ---------------------------------------------------------------------------- | | `path` | \`Path | None\` | Optional path for PNG file. If provided, PNG will be moved to this location. | Returns: | Type | Description | | ------ | ------------------------------------------------------------------------ | | `Path` | Path to the PNG screenshot. JPEG is saved alongside with .jpg extension. | Note The response now includes both 'path' (PNG) and 'jpeg_path' (JPEG) keys. This method maintains backward compatibility by returning the PNG path. Source code in `src/balatrobot/client.py` ```python def screenshot(self, path: Path | None = None) -> Path: """ Take a screenshot and save as both PNG and JPEG formats. Args: path: Optional path for PNG file. If provided, PNG will be moved to this location. Returns: Path to the PNG screenshot. JPEG is saved alongside with .jpg extension. Note: The response now includes both 'path' (PNG) and 'jpeg_path' (JPEG) keys. This method maintains backward compatibility by returning the PNG path. """ screenshot_response = self.send_message("screenshot", {}) if path is None: return Path(screenshot_response["path"]) else: source_path = Path(screenshot_response["path"]) dest_path = path shutil.move(source_path, dest_path) return dest_path ``` #### `send_message(name, arguments=None)` Send JSON message to Balatro and receive response Parameters: | Name | Type | Description | Default | | ----------- | ------ | --------------------- | ------------------ | | `name` | `str` | Function name to call | *required* | | `arguments` | \`dict | None\` | Function arguments | Returns: | Type | Description | | ------ | -------------------------- | | `dict` | Response from the game API | Raises: | Type | Description | | ----------------------- | ---------------------------- | | `ConnectionFailedError` | If not connected to the game | | `BalatroError` | If the API returns an error | Source code in `src/balatrobot/client.py` ```python def send_message(self, name: str, arguments: dict | None = None) -> dict: """Send JSON message to Balatro and receive response Args: name: Function name to call arguments: Function arguments Returns: Response from the game API Raises: ConnectionFailedError: If not connected to the game BalatroError: If the API returns an error """ if arguments is None: arguments = {} if not self._connected or not self._socket: raise ConnectionFailedError( "Not connected to the game API", error_code="E008", context={ "connected": self._connected, "socket": self._socket is not None, }, ) # Create and validate request request = APIRequest(name=name, arguments=arguments) logger.debug(f"Sending API request: {name}") try: # Send request message = request.model_dump_json() + "\n" self._socket.send(message.encode()) # Receive response using improved message handling complete_message = self._receive_complete_message() # Decode and validate the message message_str = complete_message.decode().strip() logger.debug(f"Raw message length: {len(message_str)} characters") logger.debug(f"Message preview: {message_str[:100]}...") # Ensure the message is properly formatted JSON if not message_str: raise BalatroError( "Empty response received from game", error_code="E001", context={"raw_data_length": len(complete_message)}, ) response_data = json.loads(message_str) # Check for error response if "error" in response_data: logger.error(f"API request {name} failed: {response_data.get('error')}") raise create_exception_from_error_response(response_data) logger.debug(f"API request {name} completed successfully") return response_data except socket.error as e: logger.error(f"Socket error during API request {name}: {e}") raise ConnectionFailedError( f"Socket error during communication: {e}", error_code="E008", context={"error": str(e)}, ) from e except json.JSONDecodeError as e: logger.error(f"Invalid JSON response from API request {name}: {e}") logger.error(f"Problematic message content: {message_str[:200]}...") logger.error( f"Message buffer state: {len(self._message_buffer)} bytes remaining" ) # Clear the message buffer to prevent cascading errors if self._message_buffer: logger.warning("Clearing message buffer due to JSON parse error") self._message_buffer = b"" raise BalatroError( f"Invalid JSON response from game: {e}", error_code="E001", context={"error": str(e), "message_preview": message_str[:100]}, ) from e ``` ______________________________________________________________________ ## Enums ### `balatrobot.enums.State` Game state values representing different phases of gameplay in Balatro, from menu navigation to active card play and shop interactions. Source code in `src/balatrobot/enums.py` ```python @unique class State(Enum): """Game state values representing different phases of gameplay in Balatro, from menu navigation to active card play and shop interactions.""" SELECTING_HAND = 1 HAND_PLAYED = 2 DRAW_TO_HAND = 3 GAME_OVER = 4 SHOP = 5 PLAY_TAROT = 6 BLIND_SELECT = 7 ROUND_EVAL = 8 TAROT_PACK = 9 PLANET_PACK = 10 MENU = 11 TUTORIAL = 12 SPLASH = 13 SANDBOX = 14 SPECTRAL_PACK = 15 DEMO_CTA = 16 STANDARD_PACK = 17 BUFFOON_PACK = 18 NEW_ROUND = 19 ``` ### `balatrobot.enums.Actions` Bot action values corresponding to user interactions available in different game states, from card play to shop purchases and inventory management. Source code in `src/balatrobot/enums.py` ```python @unique class Actions(Enum): """Bot action values corresponding to user interactions available in different game states, from card play to shop purchases and inventory management.""" SELECT_BLIND = 1 SKIP_BLIND = 2 PLAY_HAND = 3 DISCARD_HAND = 4 END_SHOP = 5 REROLL_SHOP = 6 BUY_CARD = 7 BUY_VOUCHER = 8 BUY_BOOSTER = 9 SELECT_BOOSTER_CARD = 10 SKIP_BOOSTER_PACK = 11 SELL_JOKER = 12 USE_CONSUMABLE = 13 SELL_CONSUMABLE = 14 REARRANGE_JOKERS = 15 REARRANGE_CONSUMABLES = 16 REARRANGE_HAND = 17 PASS = 18 START_RUN = 19 SEND_GAMESTATE = 20 ``` ### `balatrobot.enums.Decks` Starting deck types in Balatro, each providing unique starting conditions, card modifications, or special abilities that affect gameplay throughout the run. Source code in `src/balatrobot/enums.py` ```python @unique class Decks(Enum): """Starting deck types in Balatro, each providing unique starting conditions, card modifications, or special abilities that affect gameplay throughout the run.""" RED = "Red Deck" BLUE = "Blue Deck" YELLOW = "Yellow Deck" GREEN = "Green Deck" BLACK = "Black Deck" MAGIC = "Magic Deck" NEBULA = "Nebula Deck" GHOST = "Ghost Deck" ABANDONED = "Abandoned Deck" CHECKERED = "Checkered Deck" ZODIAC = "Zodiac Deck" PAINTED = "Painted Deck" ANAGLYPH = "Anaglyph Deck" PLASMA = "Plasma Deck" ERRATIC = "Erratic Deck" ``` ### `balatrobot.enums.Stakes` Difficulty stake levels in Balatro that increase game difficulty through various modifiers and restrictions, with higher stakes providing greater challenges and rewards. Source code in `src/balatrobot/enums.py` ```python @unique class Stakes(Enum): """Difficulty stake levels in Balatro that increase game difficulty through various modifiers and restrictions, with higher stakes providing greater challenges and rewards.""" WHITE = 1 RED = 2 GREEN = 3 BLACK = 4 BLUE = 5 PURPLE = 6 ORANGE = 7 GOLD = 8 ``` ### `balatrobot.enums.ErrorCode` Standardized error codes used in BalatroBot API that match those defined in src/lua/api.lua for consistent error handling across the entire system. Source code in `src/balatrobot/enums.py` ```python @unique class ErrorCode(Enum): """Standardized error codes used in BalatroBot API that match those defined in src/lua/api.lua for consistent error handling across the entire system.""" # Protocol errors (E001-E005) INVALID_JSON = "E001" MISSING_NAME = "E002" MISSING_ARGUMENTS = "E003" UNKNOWN_FUNCTION = "E004" INVALID_ARGUMENTS = "E005" # Network errors (E006-E008) SOCKET_CREATE_FAILED = "E006" SOCKET_BIND_FAILED = "E007" CONNECTION_FAILED = "E008" # Validation errors (E009-E012) INVALID_GAME_STATE = "E009" INVALID_PARAMETER = "E010" PARAMETER_OUT_OF_RANGE = "E011" MISSING_GAME_OBJECT = "E012" # Game logic errors (E013-E016) DECK_NOT_FOUND = "E013" INVALID_CARD_INDEX = "E014" NO_DISCARDS_LEFT = "E015" INVALID_ACTION = "E016" ``` ______________________________________________________________________ ## Exceptions ### Connection and Socket Errors #### `balatrobot.exceptions.SocketCreateFailedError` Socket creation failed (E006). #### `balatrobot.exceptions.SocketBindFailedError` Socket bind failed (E007). #### `balatrobot.exceptions.ConnectionFailedError` Connection failed (E008). ### Game State and Logic Errors #### `balatrobot.exceptions.InvalidGameStateError` Invalid game state for requested action (E009). #### `balatrobot.exceptions.InvalidActionError` Invalid action for current context (E016). #### `balatrobot.exceptions.DeckNotFoundError` Deck not found (E013). #### `balatrobot.exceptions.InvalidCardIndexError` Invalid card index (E014). #### `balatrobot.exceptions.NoDiscardsLeftError` No discards remaining (E015). ### API and Parameter Errors #### `balatrobot.exceptions.InvalidJSONError` Invalid JSON in request (E001). #### `balatrobot.exceptions.MissingNameError` Message missing required 'name' field (E002). #### `balatrobot.exceptions.MissingArgumentsError` Message missing required 'arguments' field (E003). #### `balatrobot.exceptions.UnknownFunctionError` Unknown function name (E004). #### `balatrobot.exceptions.InvalidArgumentsError` Invalid arguments provided (E005). #### `balatrobot.exceptions.InvalidParameterError` Invalid or missing required parameter (E010). #### `balatrobot.exceptions.ParameterOutOfRangeError` Parameter value out of valid range (E011). #### `balatrobot.exceptions.MissingGameObjectError` Required game object missing (E012). ______________________________________________________________________ ## Models The BalatroBot API uses Pydantic models to provide type-safe data structures that exactly match the game's internal state representation. All models inherit from `BalatroBaseModel` which provides consistent validation and serialization. #### Base Model #### `balatrobot.models.BalatroBaseModel` Base model for all BalatroBot API models. ### Request Models These models define the structure for specific API requests: #### `balatrobot.models.StartRunRequest` Request model for starting a new run. #### `balatrobot.models.BlindActionRequest` Request model for skip or select blind actions. #### `balatrobot.models.HandActionRequest` Request model for playing hand or discarding cards. #### `balatrobot.models.ShopActionRequest` Request model for shop actions. ### Game State Models The game state models provide comprehensive access to all Balatro game information, structured hierarchically to match the Lua API: #### Root Game State #### `balatrobot.models.G` Root game state response matching G in Lua types. ##### `state_enum` Get the state as an enum value. ##### `convert_empty_list_to_none_for_hand(v)` Convert empty list to None for hand field. #### Game Information #### `balatrobot.models.GGame` Game state matching GGame in Lua types. ##### `convert_empty_list_to_dict(v)` Convert empty list to empty dict. ##### `convert_empty_list_to_none(v)` Convert empty list to None for optional nested objects. #### `balatrobot.models.GGameCurrentRound` Current round info matching GGameCurrentRound in Lua types. ##### `convert_empty_list_to_dict(v)` Convert empty list to empty dict. #### `balatrobot.models.GGameLastBlind` Last blind info matching GGameLastBlind in Lua types. #### `balatrobot.models.GGamePreviousRound` Previous round info matching GGamePreviousRound in Lua types. #### `balatrobot.models.GGameProbabilities` Game probabilities matching GGameProbabilities in Lua types. #### `balatrobot.models.GGamePseudorandom` Pseudorandom data matching GGamePseudorandom in Lua types. #### `balatrobot.models.GGameRoundBonus` Round bonus matching GGameRoundBonus in Lua types. #### `balatrobot.models.GGameRoundScores` Round scores matching GGameRoundScores in Lua types. #### `balatrobot.models.GGameSelectedBack` Selected deck info matching GGameSelectedBack in Lua types. #### `balatrobot.models.GGameShop` Shop configuration matching GGameShop in Lua types. #### `balatrobot.models.GGameStartingParams` Starting parameters matching GGameStartingParams in Lua types. #### `balatrobot.models.GGameTags` Game tags model matching GGameTags in Lua types. #### Hand Management #### `balatrobot.models.GHand` Hand structure matching GHand in Lua types. #### `balatrobot.models.GHandCards` Hand card matching GHandCards in Lua types. #### `balatrobot.models.GHandCardsBase` Hand card base properties matching GHandCardsBase in Lua types. ##### `convert_int_to_string(v)` Convert integer values to strings. #### `balatrobot.models.GHandCardsConfig` Hand card configuration matching GHandCardsConfig in Lua types. #### `balatrobot.models.GHandCardsConfigCard` Hand card config card data matching GHandCardsConfigCard in Lua types. #### `balatrobot.models.GHandConfig` Hand configuration matching GHandConfig in Lua types. #### Joker Information #### `balatrobot.models.GJokersCards` Joker card matching GJokersCards in Lua types. #### `balatrobot.models.GJokersCardsConfig` Joker card configuration matching GJokersCardsConfig in Lua types. ### Communication Models These models handle the communication protocol between your bot and the game: #### `balatrobot.models.APIRequest` Model for API requests sent to the game. #### `balatrobot.models.APIResponse` Model for API responses from the game. #### `balatrobot.models.ErrorResponse` Model for API error responses matching Lua ErrorResponse. #### `balatrobot.models.JSONLLogEntry` Model for JSONL log entries that record game actions. ## Usage Examples For practical implementation examples: - Follow the [Developing Bots](../developing-bots/) guide for complete bot setup - Understand the underlying [Protocol API](../protocol-api/) for advanced usage - Reference the [Installation](../installation/) guide for environment setup # Contributing to BalatroBot Welcome to BalatroBot! We're excited that you're interested in contributing to this Python framework and Lua mod for creating automated bots to play Balatro. BalatroBot uses a dual-architecture approach with a Python framework that communicates with a Lua mod running inside Balatro via TCP sockets. This allows for real-time bot automation and game state analysis. ## Project Status & Priorities We track all development work using the [BalatroBot GitHub Project](https://github.com/orgs/coder/projects). This is the best place to see current priorities, ongoing work, and opportunities for contribution. ## Getting Started ### Prerequisites Before contributing, ensure you have: - **Balatro**: Version 1.0.1o-FULL - **SMODS (Steamodded)**: Version 1.0.0-beta-0711a or newer - **Python**: 3.13+ (managed via uv) - **uv**: Python package manager ([Installation Guide](https://docs.astral.sh/uv/)) - **OS**: macOS, Linux. Windows is not currently supported - **[DebugPlus](https://github.com/WilsontheWolf/DebugPlus) (optional)**: useful for Lua API development and debugging ### Development Environment Setup 1. **Fork and Clone** ```bash git clone https://github.com/YOUR_USERNAME/balatrobot.git cd balatrobot ``` 1. **Install Dependencies** ```bash make install-dev ``` 1. **Start Balatro with Mods** ```bash ./balatro.sh -p 12346 ``` 1. **Verify Balatro is Running** ```bash # Check if Balatro is running ./balatro.sh --status # Monitor startup logs tail -n 100 logs/balatro_12346.log ``` Look for these success indicators: - "BalatrobotAPI initialized" - "BalatroBot loaded - version X.X.X" - "TCP socket created on port 12346" ## How to Contribute ### Types of Contributions Welcome - **Bug Fixes**: Issues tracked in our GitHub project - **Feature Development**: New bot strategies, API enhancements - **Performance Improvements**: Optimization of TCP communication or game interaction - **Documentation**: Improvements to guides, API documentation, or examples - **Testing**: Additional test coverage, edge case handling ### Contribution Workflow 1. **Check Issues First** (Highly Encouraged) - Browse the [BalatroBot GitHub Project](https://github.com/orgs/coder/projects) - Comment on issues you'd like to work on - Create new issues for bugs or feature requests 1. **Fork & Branch** ```bash git checkout -b feature/your-feature-name ``` 1. **Make Changes** - Follow our code style guidelines (see below) - Add tests for new functionality - Update documentation as needed 1. **Create Pull Request** - **Important**: Enable "Allow edits from maintainers" when creating your PR - Link to related issues - Provide clear description of changes - Include tests for new functionality ### Commit Messages We highly encourage following [Conventional Commits](https://www.conventionalcommits.org/) format: ```text feat(api): add new game state detection fix(tcp): resolve connection timeout issues docs(readme): update setup instructions test(api): add shop booster validation tests ``` ## Development & Testing ### Makefile Commands BalatroBot includes a comprehensive Makefile that provides a convenient interface for all development tasks. Use `make help` to see all available commands: ```bash # Show all available commands with descriptions make help ``` #### Installation & Setup ```bash make install # Install package dependencies make install-dev # Install with development dependencies ``` #### Code Quality & Formatting ```bash make lint # Run ruff linter (check only) make lint-fix # Run ruff linter with auto-fixes make format # Run ruff formatter and stylua make format-md # Run markdown formatter make typecheck # Run type checker make quality # Run all code quality checks make dev # Quick development check (format + lint + typecheck, no tests) ``` ### Testing Requirements #### Testing with Makefile ```bash make test # Run tests with single instance (auto-starts if needed) make test-parallel # Run tests on 4 instances (auto-starts if needed) make test-teardown # Kill all Balatro instances # Complete workflow including tests make all # Run format + lint + typecheck + test ``` The testing system automatically handles Balatro instance management: - **`make test`**: Runs tests with a single instance, auto-starting if needed - **`make test-parallel`**: Runs tests on 4 instances for ~4x speedup, auto-starting if needed - **`make test-teardown`**: Cleans up all instances when done Both test commands keep instances running after completion for faster subsequent runs. #### Using Checkpoints for Test Setup The checkpointing system allows you to save and load specific game states, significantly speeding up test setup: **Creating Test Checkpoints:** ```bash # Create a checkpoint at a specific game state python scripts/create_test_checkpoint.py shop tests/lua/endpoints/checkpoints/shop_state.jkr python scripts/create_test_checkpoint.py blind_select tests/lua/endpoints/checkpoints/blind_select.jkr python scripts/create_test_checkpoint.py in_game tests/lua/endpoints/checkpoints/in_game.jkr ``` **Using Checkpoints in Tests:** ```python # In conftest.py or test files from ..conftest import prepare_checkpoint def setup_and_teardown(tcp_client): # Load a checkpoint directly (no restart needed!) checkpoint_path = Path(__file__).parent / "checkpoints" / "shop_state.jkr" game_state = prepare_checkpoint(tcp_client, checkpoint_path) assert game_state["state"] == State.SHOP.value ``` **Benefits of Checkpoints:** - **Faster Tests**: Skip manual game setup steps (particularly helpful for edge cases) - **Consistency**: Always start from exact same state - **Reusability**: Share checkpoints across multiple tests - **No Restarts**: Uses `load_save` API to load directly from any game state **Python Client Methods:** ```python from balatrobot import BalatroClient with BalatroClient() as client: # Save current game state as checkpoint client.save_checkpoint("tests/fixtures/my_state.jkr") # Load a checkpoint for testing save_path = client.prepare_save("tests/fixtures/my_state.jkr") game_state = client.load_save(save_path) ``` **Manual Setup for Advanced Testing:** ```bash # Check/manage Balatro instances ./balatro.sh --status # Show running instances ./balatro.sh --kill # Kill all instances # Start instances manually ./balatro.sh -p 12346 -p 12347 # Two instances ./balatro.sh --headless --fast -p 12346 -p 12347 -p 12348 -p 12349 # Full setup ./balatro.sh --audio -p 12346 # With audio enabled # Manual parallel testing pytest -n 4 --port 12346 --port 12347 --port 12348 --port 12349 tests/lua/ ``` **Performance Modes:** - **`--headless`**: No graphics, ideal for servers - **`--fast`**: 10x speed, disabled effects, optimal for testing - **`--audio`**: Enable audio (disabled by default for performance) ### Documentation ```bash make docs-serve # Serve documentation locally make docs-build # Build documentation make docs-clean # Clean built documentation ``` ### Build & Maintenance ```bash make build # Build package for distribution make clean # Clean build artifacts and caches ``` ## Technical Guidelines ### Python Development - **Style**: Follow modern Python 3.13+ patterns - **Type Hints**: Use pipe operator for unions (`str | int | None`) - **Type Aliases**: Use `type` statement - **Docstrings**: Google-style without type information (types in annotations) - **Generics**: Modern syntax (`class Container[T]:`) ### Lua Development - **Focus Area**: Primary development is on `src/lua/api.lua` - **Communication**: TCP protocol on port 12346 - **Debugging**: Use DebugPlus mod for enhanced debugging capabilities ### Environment Variables Configure BalatroBot behavior with these environment variables: - **`BALATROBOT_HEADLESS=1`**: Disable graphics for server environments - **`BALATROBOT_FAST=1`**: Enable 10x speed with disabled effects for testing - **`BALATROBOT_AUDIO=1`**: Enable audio (disabled by default for performance) - **`BALATROBOT_PORT`**: TCP communication port (default: "12346") ## Communication & Community ### Preferred Channels - **GitHub Issues**: Primary communication for bugs, features, and project coordination - **Discord**: Join us at the [Balatro Discord](https://discord.com/channels/1116389027176787968/1391371948629426316) for real-time discussions Happy contributing! # Developing Bots BalatroBot allows you to create automated players (bots) that can play Balatro by implementing decision-making logic in Python. Your bot communicates with the game through a TCP socket connection, sending actions to perform and receiving back the game state. ## Bot Architecture A bot is a finite state machine that implements a sequence of actions to play the game. The bot can be in one state at a time and has access to a set of functions that can move the bot to other states. | **State** | **Description** | **Functions** | | ---------------- | -------------------------------------------- | ---------------------------------------- | | `MENU` | The main menu | `start_run` | | `BLIND_SELECT` | Selecting or skipping the blind | `skip_or_select_blind` | | `SELECTING_HAND` | Selecting cards to play or discard | `play_hand_or_discard`, `rearrange_hand` | | `ROUND_EVAL` | Evaluating the round outcome and cashing out | `cash_out` | | `SHOP` | Buy items and move to the next round | `shop` | | `GAME_OVER` | Game has ended | – | Developing a bot boils down to providing the action name and its parameters for each state. ### State Diagram The following diagram illustrates the possible states of the game and how the functions can be used to move the bot between them: - Start (◉) and End (⦾) states - States are written in uppercase (e.g., `MENU`, `BLIND_SELECT`, ...) - Functions are written in lowercase (e.g., `start_run`, `skip_or_select_blind`, ...) - Function parameters are written in italics (e.g., `action = play_hand`). Not all parameters are reported in the diagram. - Comments are reported in parentheses (e.g., `(win round)`, `(lose round)`). - Abstract groups are written with capital letters (e.g., `Run`, `Round`, ...) ``` stateDiagram-v2 direction TB BLIND_SELECT_1:BLIND_SELECT [*] --> MENU: go_to_menu MENU --> BLIND_SELECT: start_run state Run{ BLIND_SELECT --> skip_or_select_blind skip_or_select_blind --> BLIND_SELECT: *action = skip*
(small or big blind) skip_or_select_blind --> SELECTING_HAND: *action = select* state Round { SELECTING_HAND --> play_hand_or_discard play_hand_or_discard --> SELECTING_HAND: *action = play_hand* play_hand_or_discard --> SELECTING_HAND: *action = discard* play_hand_or_discard --> ROUND_EVAL: *action = play_hand*
(win round) play_hand_or_discard --> GAME_OVER: *action = play_hand*
(lose round) } state RoundEval { ROUND_EVAL --> SHOP: *cash_out* } state Shop { SHOP --> shop shop --> BLIND_SELECT_1: *action = next_round* } state GameOver { GAME_OVER --> [*] } } state skip_or_select_blind <> state play_hand_or_discard <> state shop <> ``` ## Development Environment Setup The BalatroBot project provides a complete development environment with all necessary tools and resources for developing bots. ### Environment Setup Before developing or running bots, you need to set up the development environment by configuring the `.envrc` file: ```sh cd %AppData%/Balatro/Mods/balatrobot copy .envrc.example .envrc .envrc ``` ```sh cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot" cp .envrc.example .envrc source .envrc ``` ```sh cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot cp .envrc.example .envrc source .envrc ``` Always Source Environment Remember to source the `.envrc` file every time you start a new terminal session before developing or running bots. The environment variables are essential for proper bot functionality. Automatic Environment Loading with direnv For a better development experience, consider using [direnv](https://direnv.net/) to automatically load and unload environment variables when entering and leaving the project directory. After installing direnv and hooking it into your shell: ```sh # Allow direnv to load the .envrc file automatically direnv allow . ``` This eliminates the need to manually source `.envrc` every time you work on the project. ### Bot File Location When developing new bots, place your files in the `bots/` directory using one of these recommended patterns: - **Single file bots**: `bots/my_new_bot.py` - **Complex bots**: `bots/my_new_bot/main.py` (for bots with multiple modules) ## Next Steps After setting up your development environment: - Explore the [BalatroBot API](../balatrobot-api/) for detailed client and model documentation - Learn about the underlying [Protocol API](../protocol-api/) for TCP communication details # Installation Guide This guide will walk you through installing and setting up BalatroBot. ## Prerequisites Before installing BalatroBot, ensure you have: - **[balatro](https://store.steampowered.com/app/2379780/Balatro/)**: Steam version (>= 1.0.1) - **[git](https://git-scm.com/downloads)**: for cloning the repository - **[uv](https://docs.astral.sh/uv/)**: for managing Python installations, environments, and dependencies - **[lovely](https://github.com/ethangreen-dev/lovely-injector)**: for injecting Lua code into Balatro (>= 0.8.0) - **[steamodded](https://github.com/Steamodded/smods)**: for loading and injecting mods (>= 1.0.0) ## Step 1: Install BalatroBot BalatroBot is installed like any other Steamodded mod. ```sh cd %AppData%/Balatro mkdir -p Mods cd Mods git clone https://github.com/coder/balatrobot.git ``` ```sh cd "/Users/$USER/Library/Application Support/Balatro" mkdir -p Mods cd Mods git clone https://github.com/coder/balatrobot.git ``` ```sh cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro mkdir -p Mods cd Mods git clone https://github.com/coder/balatrobot.git ``` Tip You can also clone the repository somewhere else and then provide a symlink to the `balatrobot` directory in the `Mods` directory. ```sh # Clone repository to a custom location cd C:\your\custom\path git clone https://github.com/coder/balatrobot.git # Create symlink in Mods directory cd %AppData%/Balatro/Mods mklink /D balatrobot C:\your\custom\path\balatrobot ``` ```sh # Clone repository to a custom location cd /your/custom/path git clone https://github.com/coder/balatrobot.git # Create symlink in Mods directory cd "/Users/$USER/Library/Application Support/Balatro/Mods" ln -s /your/custom/path/balatrobot balatrobot ``` ```sh # Clone repository to a custom location cd /your/custom/path git clone https://github.com/coder/balatrobot.git # Create symlink in Mods directory cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods ln -s /your/custom/path/balatrobot balatrobot ``` Update BalatroBot Updating BalatroBot is as simple as pulling the latest changes from the repository. ```sh cd %AppData%/Balatro/Mods/balatrobot git pull ``` ```sh cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot" git pull ``` ```sh cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot git pull ``` Uninstall BalatroBot Simply delete the balatrobot mod directory. ```sh cd %AppData%/Balatro/Mods rmdir /S /Q balatrobot ``` ```sh cd "/Users/$USER/Library/Application Support/Balatro/Mods" rm -rf balatrobot ``` ```sh cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods rm -rf balatrobot ``` ## Step 2: Set Up Python Environment Uv takes care of managing Python installations, virtual environment creation, and dependency installation. To set up the Python environment for running BalatroBot bots, simply run: ```sh cd %AppData%/Balatro/Mods/balatrobot uv sync ``` ```sh cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot" uv sync ``` ```sh cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot uv sync ``` The same command can be used to update the Python environment and dependencies in the future. Remove Python Environment To uninstall the Python environment and dependencies, simply remove the `.venv` directory. ```sh cd %AppData%/Balatro/Mods/balatrobot rmdir /S /Q .venv ``` ```sh cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot" rm -rf .venv ``` ```sh cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot rm -rf .venv ``` ## Step 3: Test Installation ### Launch Balatro with Mods 1. Start Balatro through Steam 1. In the main menu, click "Mods" 1. Verify "BalatroBot" appears in the mod list 1. Enable the mod if it's not already enabled and restart the game macOS Steam Client Issue On macOS, you cannot start Balatro through the Steam App due to a bug in the Steam client. Instead, you must use the `run_lovely_macos.sh` script. ```sh cd "/Users/$USER/Library/Application Support/Steam/steamapps/common/Balatro" ./run_lovely_macos.sh ``` **First-time setup:** If this is your first time running the script, macOS Security & Privacy settings will prevent it from executing. Open **System Preferences** → **Security & Privacy** and click "Allow" when prompted, then run the script again. ### Quick Test with Example Bot With Balatro running and the mod enabled, you can quickly test if everything is set up correctly using the provided example bot. ```sh cd %AppData%/Balatro/Mods/balatrobot uv run bots/example.py ``` ```sh cd "/Users/$USER/Library/Application Support/Balatro/Mods/balatrobot" uv run bots/example.py ``` ```sh cd ~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/balatrobot uv run bots/example.py ``` Tip You can also navigate to the `balatrobot` directory, activate the Python environment and run the bot with `python bots/example.py` if you prefer. However, remember to always activate the virtual environment first. The bot is working correctly if: 1. Game starts automatically 1. Cards are played/discarded automatically 1. Win the first blind 1. Game progresses through blinds ## Troubleshooting If you encounter issues during installation or testing: - **Discord Support**: Join our community at for real-time help - **GitHub Issues**: Report bugs or request features by [opening an issue](https://github.com/coder/balatrobot/issues) on GitHub ______________________________________________________________________ *Once installation is complete, proceed to the [Developing Bots](../developing-bots/) to create your first bot!* # Protocol API This document provides the TCP API protocol reference for developers who want to interact directly with the BalatroBot game interface using raw socket connections. ## Protocol The BalatroBot API establishes a TCP socket connection to communicate with the Balatro game through the BalatroBot Lua mod. The protocol uses a simple JSON request-response model for synchronous communication. - **Host:** `127.0.0.1` (default, configurable via `BALATROBOT_HOST`) - **Port:** `12346` (default, configurable via `BALATROBOT_PORT`) - **Message Format:** JSON ### Configuration The API server can be configured using environment variables: - `BALATROBOT_HOST`: The network interface to bind to (default: `127.0.0.1`) - `127.0.0.1`: Localhost only (secure for local development) - `*` or `0.0.0.0`: All network interfaces (required for Docker or remote access) - `BALATROBOT_PORT`: The TCP port to listen on (default: `12346`) - `BALATROBOT_HEADLESS`: Enable headless mode (`1` to enable) - `BALATROBOT_FAST`: Enable fast mode for faster gameplay (`1` to enable) ### Communication Sequence The typical interaction follows a game loop where clients continuously query the game state, analyze it, and send appropriate actions: ``` sequenceDiagram participant Client participant BalatroBot loop Game Loop Client->>BalatroBot: {"name": "get_game_state", "arguments": {}} BalatroBot->>Client: {game state JSON} Note over Client: Analyze game state and decide action Client->>BalatroBot: {"name": "function_name", "arguments": {...}} alt Valid Function Call BalatroBot->>Client: {updated game state} else Error BalatroBot->>Client: {"error": "description", ...} end end ``` ### Message Format All communication uses JSON messages with a standardized structure. The protocol defines three main message types: function call requests, successful responses, and error responses. **Request Format:** ```json { "name": "function_name", "arguments": { "param1": "value1", "param2": ["array", "values"] } } ``` **Response Format:** ```json { "state": 7, "game": { ... }, "hand": [ ... ], "jokers": [ ... ] } ``` **Error Response Format:** ```json { "error": "Error message description", "error_code": "E001", "state": 7, "context": { "additional": "error details" } } ``` ## Game States The BalatroBot API operates as a finite state machine that mirrors the natural flow of playing Balatro. Each state represents a distinct phase where specific actions are available. ### Overview The game progresses through these states in a typical flow: `MENU` → `BLIND_SELECT` → `SELECTING_HAND` → `ROUND_EVAL` → `SHOP` → `BLIND_SELECT` (or `GAME_OVER`). | State | Value | Description | Available Functions | | ---------------- | ----- | ---------------------------- | ------------------------------------------------------------------------------------------- | | `MENU` | 11 | Main menu screen | `start_run` | | `BLIND_SELECT` | 7 | Selecting or skipping blinds | `skip_or_select_blind`, `sell_joker`, `sell_consumable`, `use_consumable` | | `SELECTING_HAND` | 1 | Playing or discarding cards | `play_hand_or_discard`, `rearrange_hand`, `sell_joker`, `sell_consumable`, `use_consumable` | | `ROUND_EVAL` | 8 | Round completion evaluation | `cash_out`, `sell_joker`, `sell_consumable`, `use_consumable` | | `SHOP` | 5 | Shop interface | `shop`, `sell_joker`, `sell_consumable`, `use_consumable` | | `GAME_OVER` | 4 | Game ended | `go_to_menu` | ### Validation Functions can only be called when the game is in their corresponding valid states. The `get_game_state` function is available in all states. Game State Reset The `go_to_menu` function can be used in any state to reset a run. However, run resuming is not supported by BalatroBot. So performing a `go_to_menu` is effectively equivalent to resetting the run. This can be used to restart the game to a clean state. ## Game Functions The BalatroBot API provides core functions that correspond to the main game actions. Each function is state-dependent and can only be called in the appropriate game state. ### Overview | Name | Description | | ----------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | `get_game_state` | Retrieves the current complete game state | | `go_to_menu` | Returns to the main menu from any game state | | `start_run` | Starts a new game run with specified configuration | | `skip_or_select_blind` | Handles blind selection - either select the current blind to play or skip it | | `play_hand_or_discard` | Plays selected cards or discards them | | `rearrange_hand` | Reorders the current hand according to the supplied index list | | `rearrange_consumables` | Reorders the consumables according to the supplied index list | | `cash_out` | Proceeds from round completion to the shop phase | | `shop` | Performs shop actions: proceed to next round (`next_round`), purchase (and use) a card (`buy_card` | `buy_and_use_card`), or reroll shop (`reroll`) | | `sell_joker` | Sells a joker from the player's collection for money | | `sell_consumable` | Sells a consumable from the player's collection for money | | `use_consumable` | Uses a consumable card from the player's collection (Tarot, Planet, or Spectral cards) | ### Parameters The following table details the parameters required for each function. Note that `get_game_state` and `go_to_menu` require no parameters: | Name | Parameters | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `start_run` | `deck` (string): Deck name `stake` (number): Difficulty level 1-8 `seed` (string, optional): Seed for run generation `challenge` (string, optional): Challenge name `log_path` (string, optional): Full file path for run log (must include .jsonl extension) | | `skip_or_select_blind` | `action` (string): Either "select" or "skip" | | `play_hand_or_discard` | `action` (string): Either "play_hand" or "discard" `cards` (array): Card indices (0-indexed, 1-5 cards) | | `rearrange_hand` | `cards` (array): Card indices (0-indexed, exactly `hand_size` elements) | | `rearrange_consumables` | `consumables` (array): Consumable indices (0-indexed, exactly number of consumables in consumable area) | | `shop` | `action` (string): Shop action ("next_round", "buy_card", "buy_and_use_card", "reroll", or "redeem_voucher") `index` (number, required when `action` is one of "buy_card", "redeem_voucher", "buy_and_use_card"): 0-based card index to purchase / redeem | | `sell_joker` | `index` (number): 0-based index of the joker to sell from the player's joker collection | | `sell_consumable` | `index` (number): 0-based index of the consumable to sell from the player's consumable collection | | `use_consumable` | `index` (number): 0-based index of the consumable to use from the player's consumable collection | ### Shop Actions The `shop` function supports multiple in-shop actions. Use the `action` field inside the `arguments` object to specify which of these to execute. | Action | Description | Additional Parameters | | ------------------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `next_round` | Leave the shop and proceed to the next blind selection. | — | | `buy_card` | Purchase the card at the supplied `index` in `shop_jokers`. | `index` *(number)* – 0-based position of the card to buy | | `buy_and_use_card` | Purchase and use the card at the supplied `index` in `shop_jokers`; only some consumables may be bought and used. | `index` *(number)* – 0-based position of the card to buy | | `reroll` | Spend dollars to refresh the shop offer (cost shown in-game). | — | | `redeem_voucher` | Redeem the voucher at the supplied `index` in `shop_vouchers`, applying its discount or effect. | `index` *(number)* – 0-based position of the voucher to redeem | Future actions Additional shop actions such as `buy_and_use_card` and `open_pack` are planned. ### Development Tools These endpoints are primarily for development, testing, and debugging purposes: #### `get_save_info` Returns information about the current save file location and profile. **Arguments:** None **Returns:** - `profile_path` *(string)* – Current profile path (e.g., "3") - `save_directory` *(string)* – Full path to Love2D save directory - `save_file_path` *(string)* – Full OS-specific path to save.jkr file - `has_active_run` *(boolean)* – Whether a run is currently active - `save_exists` *(boolean)* – Whether a save file exists #### `load_save` Loads a save file directly without requiring a game restart. This is useful for testing specific game states. **Arguments:** - `save_path` *(string)* – Path to the save file relative to Love2D save directory (e.g., "3/save.jkr") **Returns:** Game state after loading the save Development Use These endpoints are intended for development and testing. The `load_save` function bypasses normal game flow and should be used carefully. ### Errors All API functions validate their inputs and game state before execution. Error responses include an `error` message, standardized `error_code`, current `state` value, and optional `context` with additional details. | Code | Category | Error | | ------ | ---------- | ------------------------------------------ | | `E001` | Protocol | Invalid JSON in request | | `E002` | Protocol | Message missing required 'name' field | | `E003` | Protocol | Message missing required 'arguments' field | | `E004` | Protocol | Unknown function name | | `E005` | Protocol | Arguments must be a table | | `E006` | Network | Socket creation failed | | `E007` | Network | Socket bind failed | | `E008` | Network | Connection failed | | `E009` | Validation | Invalid game state for requested action | | `E010` | Validation | Invalid or missing required parameter | | `E011` | Validation | Parameter value out of valid range | | `E012` | Validation | Required game object missing | | `E013` | Game Logic | Deck not found | | `E014` | Game Logic | Invalid card index | | `E015` | Game Logic | No discards remaining | | `E016` | Game Logic | Invalid action for current context | ## Implementation For higher-level integration: - Use the [BalatroBot API](../balatrobot-api/) `BalatroClient` for managed connections - See [Developing Bots](../developing-bots/) for complete bot implementation examples