# 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