Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Controls the game lifecycle and provides the CLI.
- **CLI** (`cli.py`): Entry point (`balatrobot`). Handles arguments like `--fast`, `--debug`, `--headless`.
- **Manager** (`manager.py`): `BalatroInstance` context manager. Starts the game process, handles logging, and waits for the API to be healthy.
- **Config** (`config.py`): Configuration management using `dataclasses` and environment variables.
- **Platform Abstraction** (`platforms/`): Cross-platform game launcher system with platform-specific implementations for macOS, Windows, and native Love2D.
- **Platform Abstraction** (`platforms/`): Cross-platform game launcher system with platform-specific implementations for macOS, Windows, Linux (Proton), and native Love2D.

### 2. Lua Layer (`src/lua/`)

Expand Down
30 changes: 28 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ All options can be set via CLI flags or environment variables. CLI flags overrid
| `--pixel-art-smoothing` | `BALATROBOT_PIXEL_ART_SMOOTHING` | `0` | Enable pixel art smoothing |
| `--balatro-path BALATRO_PATH` | `BALATROBOT_BALATRO_PATH` | auto-detected | Path to Balatro game directory |
| `--lovely-path LOVELY_PATH` | `BALATROBOT_LOVELY_PATH` | auto-detected | Path to lovely library (dll/so/dylib) |
| `--love-path LOVE_PATH` | `BALATROBOT_LOVE_PATH` | auto-detected | Path to LOVE executable (native only) |
| `--platform PLATFORM` | `BALATROBOT_PLATFORM` | auto-detected | Platform: darwin, linux, windows, native |
| `--love-path LOVE_PATH` | `BALATROBOT_LOVE_PATH` | auto-detected | Path to game launcher executable |
| `--platform PLATFORM` | `BALATROBOT_PLATFORM` | auto-detected | Platform: darwin, proton, windows, native |
| `--logs-path LOGS_PATH` | `BALATROBOT_LOGS_PATH` | `logs` | Directory for log files |
| `-h, --help` | - | - | Show help message and exit |

Expand Down Expand Up @@ -223,6 +223,32 @@ uvx balatrobot serve --fast
uvx balatrobot serve --love-path "/path/to/love" --lovely-path "/path/to/liblovely.dylib"
```

### Linux (Proton) Platform

The `proton` platform launches Balatro via Steam Proton on Linux. The CLI auto-detects Steam and Proton installation paths:

**Auto-Detected Paths:**

- `BALATROBOT_BALATRO_PATH`: `~/.local/share/Steam/steamapps/common/Balatro`
- `BALATROBOT_LOVE_PATH`: Best available Proton executable (prefers GE-Proton > official Proton > Experimental)
- `BALATROBOT_LOVELY_PATH`: `~/.local/share/Steam/steamapps/common/Balatro/version.dll`

**Requirements:**

- Balatro installed via Steam with Proton
- [Lovely Injector](https://github.com/ethangreen-dev/lovely-injector) `version.dll` (Windows version) placed in the Balatro game directory
- Mods directory: `~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods`

**Launch:**

```bash
# Explicit proton platform (required on Linux)
uvx balatrobot serve --platform proton --fast

# Or specify custom paths
uvx balatrobot serve --love-path /path/to/proton --balatro-path /path/to/Balatro
```

### Native Platform (Linux Only)

The `native` platform runs Balatro from source code using the LÖVE framework installed via package manager. This requires specific directory structure:
Expand Down
13 changes: 6 additions & 7 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

Guide for contributing to BalatroBot development.

!!! warning "Help Needed: Linux (Proton) Support"
## Supported Platforms

We currently lack CLI support for **Linux (Proton)**. Contributions to implement this platform are highly welcome!
Platform-specific launchers are located in `src/balatrobot/platforms/`:

Please refer to the existing implementations for guidance:

- **macOS:** `src/balatrobot/platforms/macos.py`
- **Windows:** `src/balatrobot/platforms/windows.py`
- **Linux (Native):** `src/balatrobot/platforms/native.py`
- **macOS:** `src/balatrobot/platforms/macos.py`
- **Linux (Proton):** `src/balatrobot/platforms/proton.py`
- **Windows:** `src/balatrobot/platforms/windows.py`
- **Linux (Native):** `src/balatrobot/platforms/native.py`

## Prerequisites

Expand Down
35 changes: 27 additions & 8 deletions src/balatrobot/cli/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@
from balatrobot.manager import BalatroInstance

# Platform choices for validation
PLATFORM_CHOICES = ["darwin", "linux", "windows", "native"]
PLATFORM_CHOICES = ["darwin", "proton", "windows", "native"]


def serve(
# fmt: off
attach: Annotated[
bool,
typer.Option(
help="Attach to a game already launched externally (e.g. snap/Flatpak Steam). "
"Skips launching the game and waits up to 5 minutes for it to come up."
),
] = False,
host: Annotated[
str | None, typer.Option(help="Server hostname (default: 127.0.0.1)")
] = None,
Expand Down Expand Up @@ -55,10 +62,10 @@ def serve(
str | None, typer.Option(help="Path to lovely library")
] = None,
love_path: Annotated[
str | None, typer.Option(help="Path to LOVE executable")
str | None, typer.Option(help="Path to game launcher executable")
] = None,
platform: Annotated[
str | None, typer.Option(help="Platform (darwin, linux, windows, native)")
str | None, typer.Option(help="Platform (darwin, proton, windows, native)")
] = None,
# fmt: on
) -> None:
Expand Down Expand Up @@ -95,14 +102,26 @@ def serve(
)

try:
asyncio.run(_serve(config))
asyncio.run(_serve(config, attach=attach))
except KeyboardInterrupt:
typer.echo("\nShutting down server...")


async def _serve(config: Config) -> None:
async def _serve(config: Config, attach: bool = False) -> None:
"""Async serve implementation."""
async with BalatroInstance(config) as instance:
if attach:
instance = BalatroInstance(config)
await instance.attach()
typer.echo(f"Balatro running on port {instance.port}. Press Ctrl+C to stop.")
while True:
await asyncio.sleep(5)
try:
while True:
await asyncio.sleep(5)
finally:
pass
else:
async with BalatroInstance(config) as instance:
typer.echo(
f"Balatro running on port {instance.port}. Press Ctrl+C to stop."
)
while True:
await asyncio.sleep(5)
23 changes: 21 additions & 2 deletions src/balatrobot/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from balatrobot.config import Config
from balatrobot.platforms import get_launcher

HEALTH_TIMEOUT = 30.0
HEALTH_TIMEOUT = 90.0
ATTACH_TIMEOUT = 300.0


class BalatroInstance:
Expand Down Expand Up @@ -63,7 +64,12 @@ async def _wait_for_health(self, timeout: float = HEALTH_TIMEOUT) -> None:
data = response.json()
if "result" in data and data["result"].get("status") == "ok":
return
except (httpx.ConnectError, httpx.TimeoutException):
except (
httpx.ConnectError,
httpx.TimeoutException,
httpx.ReadError,
httpx.RemoteProtocolError,
):
pass
await asyncio.sleep(0.5)

Expand Down Expand Up @@ -99,6 +105,19 @@ async def start(self) -> None:

print(f"Balatro started (PID: {self._process.pid})")

async def attach(self) -> None:
"""Wait for a game already launched externally to become healthy.

Use this when the game is launched via Steam directly (e.g. snap/Flatpak Steam)
and balatrobot cannot manage the process lifecycle.
"""
print(f"Waiting for Balatro on {self._config.host}:{self._config.port}...")
print("Launch the game via Steam now if you haven't already.")
try:
await self._wait_for_health(timeout=ATTACH_TIMEOUT)
except RuntimeError as e:
raise RuntimeError(str(e)) from e

async def stop(self) -> None:
"""Stop the Balatro instance."""
if self._process is None:
Expand Down
10 changes: 6 additions & 4 deletions src/balatrobot/platforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
if TYPE_CHECKING:
from balatrobot.platforms.base import BaseLauncher

VALID_PLATFORMS = frozenset({"darwin", "linux", "windows", "native"})
VALID_PLATFORMS = frozenset({"darwin", "linux", "proton", "windows", "native"})


def get_launcher(platform: str | None = None) -> "BaseLauncher":
"""Get launcher for the specified or detected platform.

Args:
platform: Optional platform to use instead of auto-detection.
Valid values: "darwin", "linux", "windows", "native"
Valid values: "darwin", "proton", "windows", "native"

Returns:
Launcher instance for the platform
Expand All @@ -39,8 +39,10 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher":
from balatrobot.platforms.macos import MacOSLauncher

return MacOSLauncher()
case "linux":
raise NotImplementedError("Linux launcher not yet implemented")
case "linux" | "proton":
from balatrobot.platforms.proton import ProtonLauncher

return ProtonLauncher()
case "windows":
from balatrobot.platforms.windows import WindowsLauncher

Expand Down
Loading
Loading