├── src
├── infinity_arcade
│ ├── version.py
│ ├── builtin_games
│ │ ├── __init__.py
│ │ ├── snake_moving_food.py
│ │ └── rainbow_space_invaders.py
│ ├── static
│ │ ├── favicon.ico
│ │ └── style.css
│ ├── __init__.py
│ ├── utils.py
│ ├── cli.py
│ ├── arcade_games.py
│ ├── game_launcher.py
│ ├── game_orchestrator.py
│ ├── templates
│ │ └── index.html
│ ├── llm_service.py
│ └── main.py
└── lemonade_client
│ └── __init__.py
├── img
├── icon.ico
└── banner.png
├── docs
├── assets
│ ├── favicon.ico
│ └── homepage.css
├── index.html
└── lemonade_client_api.md
├── pyproject.toml
├── .github
└── workflows
│ ├── lint-python.yml
│ ├── publish_to_pypi.yaml
│ ├── publish-website.yml
│ ├── build-arcade-exe.yml
│ └── test_lemonade_client.yml
├── LICENSE
├── setup.py
├── hook_pygame.py
├── .pylintrc
├── examples
├── README.md
└── lemonade_client_integration_example.py
├── test_build_setup.py
├── prompt.md
├── infinity_arcade.spec
├── .gitignore
├── README.md
└── test
└── lemonade_client_integration.py
/src/infinity_arcade/version.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.3.0"
2 |
--------------------------------------------------------------------------------
/img/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lemonade-sdk/infinity-arcade/HEAD/img/icon.ico
--------------------------------------------------------------------------------
/img/banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lemonade-sdk/infinity-arcade/HEAD/img/banner.png
--------------------------------------------------------------------------------
/docs/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lemonade-sdk/infinity-arcade/HEAD/docs/assets/favicon.ico
--------------------------------------------------------------------------------
/src/infinity_arcade/builtin_games/__init__.py:
--------------------------------------------------------------------------------
1 | # Built-in games for Infinity Arcade
2 | # Copyright (c) 2025 AMD
3 |
--------------------------------------------------------------------------------
/src/infinity_arcade/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lemonade-sdk/infinity-arcade/HEAD/src/infinity_arcade/static/favicon.ico
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = [
3 | "setuptools>=68",
4 | "wheel"
5 | ]
6 | build-backend = "setuptools.build_meta"
7 |
8 |
9 |
--------------------------------------------------------------------------------
/src/infinity_arcade/__init__.py:
--------------------------------------------------------------------------------
1 | """Infinity Arcade - AI-powered game generator and arcade."""
2 |
3 | from .version import __version__
4 |
5 | # Copyright (c) 2025 AMD
6 |
--------------------------------------------------------------------------------
/src/lemonade_client/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Lemonade Client - Python client for interacting with Lemonade Server.
3 |
4 | This module provides the LemonadeClient class for connecting to and interacting
5 | with Lemonade Server instances.
6 | """
7 |
8 | from .lemonade_client import LemonadeClient
9 |
10 | __all__ = ["LemonadeClient"]
11 |
--------------------------------------------------------------------------------
/.github/workflows/lint-python.yml:
--------------------------------------------------------------------------------
1 | name: Lint Python Code
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | lint:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - name: Checkout code
15 | uses: actions/checkout@v4
16 |
17 | - name: Set up Python
18 | uses: actions/setup-python@v4
19 | with:
20 | python-version: '3.11'
21 |
22 | - name: Install dependencies
23 | run: |
24 | python -m pip install --upgrade pip
25 | pip install pylint
26 |
27 | - name: Install project dependencies
28 | run: |
29 | pip install -e .
30 |
31 | - name: Lint with pylint
32 | run: |
33 | pylint src/infinity_arcade --rcfile .pylintrc
34 | pylint src/lemonade_client --rcfile .pylintrc
35 |
36 |
--------------------------------------------------------------------------------
/src/infinity_arcade/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 |
5 | def get_resource_path(relative_path):
6 | """Get absolute path to resource, works for dev and for PyInstaller"""
7 | try:
8 | # PyInstaller creates a temp folder and stores path in _MEIPASS
9 | # pylint: disable=protected-access,no-member
10 | base_path = sys._MEIPASS
11 | # In PyInstaller bundle, resources are under infinity_arcade/
12 | if relative_path in ["static", "templates", "builtin_games"]:
13 | return os.path.join(base_path, "infinity_arcade", relative_path)
14 | else:
15 | return os.path.join(base_path, relative_path)
16 | except Exception:
17 | # Use the directory of this file as the base path for development
18 | base_path = os.path.dirname(os.path.abspath(__file__))
19 | return os.path.join(base_path, relative_path)
20 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 AMD
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | from setuptools import setup
4 |
5 | with open("src/infinity_arcade/version.py", encoding="utf-8") as fp:
6 | version = fp.read().split('"')[1]
7 |
8 | setup(
9 | name="infinity-arcade",
10 | version=version,
11 | description="AI-powered game generator and arcade using Lemonade Server",
12 | author="Lemonade SDK",
13 | author_email="lemonade@amd.com",
14 | packages=["infinity_arcade", "infinity_arcade.builtin_games", "lemonade_client"],
15 | package_dir={"": "src"},
16 | install_requires=[
17 | "fastapi>=0.104.0",
18 | "uvicorn>=0.24.0",
19 | "pygame>=2.5.0",
20 | "httpx>=0.25.0",
21 | "jinja2>=3.1.0",
22 | "python-multipart>=0.0.6",
23 | "openai>=1.0.0",
24 | ],
25 | entry_points={
26 | "console_scripts": [
27 | "infinity-arcade=infinity_arcade.cli:main",
28 | ],
29 | "gui_scripts": [
30 | "infinity-arcade-gui=infinity_arcade.main:main",
31 | ],
32 | },
33 | python_requires=">=3.8",
34 | package_data={
35 | "infinity_arcade": ["static/**/*", "templates/**/*"],
36 | },
37 | long_description=open("README.md", "r", encoding="utf-8").read(),
38 | long_description_content_type="text/markdown",
39 | )
40 |
41 | # Copyright (c) 2025 AMD
42 |
--------------------------------------------------------------------------------
/hook_pygame.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 |
5 | # Runtime hook for pygame DLL loading in PyInstaller
6 | def setup_pygame_environment():
7 | """Set up environment for pygame DLL loading in PyInstaller bundle."""
8 | if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
9 | # We're in a PyInstaller bundle
10 | bundle_dir = sys._MEIPASS
11 |
12 | # Add the bundle directory to DLL search paths
13 | if hasattr(os, "add_dll_directory"):
14 | try:
15 | os.add_dll_directory(bundle_dir)
16 | except (OSError, FileNotFoundError):
17 | pass
18 |
19 | # Set environment variables that help with SDL2 loading and prevent SDL3
20 | os.environ["SDL_VIDEODRIVER"] = "windib"
21 | os.environ["SDL_AUDIODRIVER"] = "directsound"
22 |
23 | # Explicitly prefer SDL2 over SDL3
24 | os.environ["SDL_DYNAMIC_API"] = os.path.join(bundle_dir, "SDL2.dll")
25 |
26 | # Add bundle to PATH for DLL resolution (put it first to prioritize our DLLs)
27 | current_path = os.environ.get("PATH", "")
28 | if bundle_dir not in current_path:
29 | os.environ["PATH"] = bundle_dir + os.pathsep + current_path
30 |
31 |
32 | # Run the setup immediately when this hook is imported
33 | setup_pygame_environment()
34 |
35 | # Copyright (c) 2025 AMD
36 |
--------------------------------------------------------------------------------
/.pylintrc:
--------------------------------------------------------------------------------
1 | [MASTER]
2 |
3 | # Ignore the builtin_games folder
4 | ignore=builtin_games
5 |
6 | [MESSAGES CONTROL]
7 |
8 | # Disable the following warnings:
9 | # W1203: logging-fstring-interpolation - Using f-strings in logging calls
10 | # W0718: broad-exception-caught - Catching too general exception
11 | # C0415: import-outside-toplevel - Import outside toplevel
12 | # R0913: too-many-arguments - Too many arguments
13 | # R1705: no-else-return - Unnecessary "else" after "return", remove the "else" and de-indent the code inside it
14 | # R0917: too-many-positional-arguments - Too many positional arguments
15 | # R0912: too-many-branches - Too many branches
16 | # C0114: missing-module-docstring - Missing module docstring
17 | # R0915: too-many-statements - Too many statements
18 | # C0302: too-many-lines - Too many lines in module
19 | # R0914: too-many-locals - Too many local variables
20 | # R1723: no-else-break - Unnecessary "else" after "break", remove the "else" and de-indent the code inside it
21 | # W0702: bare-except - No exception type(s) specified
22 | # R1724: Unnecessary "else" after "continue", remove the "else" and de-indent the code inside it (no-else-continue)
23 | # C0116: missing-function-docstring
24 | # C0301: line-too-long
25 | # R0902: too-many-instance-attributes
26 | # R1732: Consider using 'with' for resource-allocating operations (consider-using-with)
27 | # R0911: Too many return statements (7/6) (too-many-return-statements)
28 |
29 |
30 | disable=W1203,W0718,C0415,R0913,R1705,R0917,R0912,C0114,R0915,C0302,R0914,R1723,W0702,R1724,C0116,C0301,R0902,R1732,R0911
31 |
--------------------------------------------------------------------------------
/.github/workflows/publish_to_pypi.yaml:
--------------------------------------------------------------------------------
1 | name: Publish Python distributions to PyPI
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | tags:
7 | - v*
8 | - RC*
9 | pull_request:
10 | merge_group:
11 |
12 | jobs:
13 | build-n-publish:
14 | name: Build and publish Python distributions to PyPI
15 | runs-on: ubuntu-latest
16 | concurrency:
17 | group: ${{ github.workflow }}-${{ github.ref }}
18 | cancel-in-progress: true
19 | steps:
20 | - uses: actions/checkout@main
21 | - uses: conda-incubator/setup-miniconda@v3
22 | with:
23 | miniconda-version: "latest"
24 | activate-environment: lemon
25 | python-version: "3.10"
26 | - name: Install pypa/build
27 | run: >-
28 | python -m pip install build --user
29 | - name: Build a binary wheel and a source tarball
30 | run: |
31 | python -m build --sdist --wheel --outdir dist/ .
32 | version=$(python setup.py --version)
33 | echo "VERSION=$version" >> $GITHUB_ENV
34 | - name: Test wheel
35 | shell: bash -el {0}
36 | run: |
37 | python -m pip install --upgrade pip
38 | pip install "dist/infinity_arcade-${{ env.VERSION }}-py3-none-any.whl[dev]"
39 | - name: Publish distribution package to PyPI
40 | if: startsWith(github.ref, 'refs/tags/v')
41 | uses: pypa/gh-action-pypi-publish@release/v1
42 | with:
43 | password: ${{ secrets.PYPI_API_TOKEN }}
44 |
45 | # This file was originally licensed under Apache 2.0. It has been modified.
46 | # Modifications Copyright (c) 2025 AMD
--------------------------------------------------------------------------------
/.github/workflows/publish-website.yml:
--------------------------------------------------------------------------------
1 | name: Publish Website
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | tags:
7 | - v*
8 | workflow_dispatch:
9 |
10 | jobs:
11 | build-n-publish-website:
12 | name: Build and publish infinity-arcade.com website
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@main
16 | with:
17 | fetch-depth: 0
18 | - uses: conda-incubator/setup-miniconda@v3
19 | with:
20 | miniconda-version: "latest"
21 | activate-environment: lemon
22 | python-version: "3.10"
23 | - name: Install dependencies
24 | shell: bash -el {0}
25 | run: |
26 | python -m pip install --upgrade pip
27 | - name: Configure git
28 | shell: bash -el {0}
29 | run: |
30 | git config --global user.name "Lemonade Bot"
31 | git config --global user.email "lemonade@amd.com"
32 | - name: Capture main branch commit hash
33 | shell: bash -el {0}
34 | run: |
35 | echo "MAIN_COMMIT_HASH=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
36 | - name: Merge into website branch
37 | shell: bash -el {0}
38 | run: |
39 | git checkout website
40 | git merge origin/main -X theirs
41 | - name: Push updates
42 | shell: bash -el {0}
43 | run: |
44 | git add .
45 | if git diff --staged --quiet; then
46 | echo "No changes to commit, skipping commit step"
47 | else
48 | git commit -m "Update website for: ${MAIN_COMMIT_HASH}"
49 | fi
50 | git push
51 |
--------------------------------------------------------------------------------
/src/infinity_arcade/cli.py:
--------------------------------------------------------------------------------
1 | """
2 | Command-line interface for Infinity Arcade
3 | """
4 |
5 | import argparse
6 | import logging
7 | import sys
8 |
9 |
10 | def main():
11 | """Main entry point for the infinity-arcade command."""
12 | parser = argparse.ArgumentParser(
13 | description="Infinity Arcade - AI-powered game creation"
14 | )
15 | parser.add_argument(
16 | "--log-level",
17 | choices=["debug", "info", "warning", "error"],
18 | default="info",
19 | help="Set the logging level (default: info)",
20 | )
21 |
22 | args = parser.parse_args()
23 |
24 | # Configure logging
25 | log_level = getattr(logging, args.log_level.upper())
26 |
27 | # Set root logger to WARNING to suppress third-party debug logs
28 | logging.basicConfig(
29 | level=logging.WARNING,
30 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
31 | stream=sys.stdout,
32 | )
33 |
34 | # Set our specific logger to the requested level
35 | logger = logging.getLogger("infinity_arcade.main")
36 | logger.setLevel(log_level)
37 |
38 | # Also set uvicorn to INFO level to reduce noise
39 | uvicorn_logger = logging.getLogger("uvicorn.access")
40 | if log_level == logging.DEBUG:
41 | uvicorn_logger.setLevel(
42 | logging.WARNING
43 | ) # Suppress uvicorn access logs in debug mode
44 |
45 | # Suppress httpx and httpcore debug logs
46 | logging.getLogger("httpx").setLevel(logging.WARNING)
47 | logging.getLogger("httpcore").setLevel(logging.WARNING)
48 |
49 | from .main import main as run_app
50 |
51 | run_app()
52 |
53 |
54 | if __name__ == "__main__":
55 | main()
56 |
57 | # Copyright (c) 2025 AMD
58 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # LemonadeClient Examples
2 |
3 | This directory contains practical examples demonstrating how to use the LemonadeClient class in your applications.
4 |
5 | ## Available Examples
6 |
7 | ### [lemonade_client_integration_example.py](lemonade_client_integration_example.py)
8 |
9 | A comprehensive example showing the complete workflow for integrating LemonadeClient into an application:
10 |
11 | - ✅ **Installation checking** - Verify lemonade-server is installed and compatible
12 | - 🚀 **Server management** - Start and verify server status
13 | - 🤖 **Model operations** - Install, load, and verify model availability
14 | - 🧪 **Inference testing** - Basic API connectivity and inference test
15 | - 📝 **Error handling** - Proper exception handling and user feedback
16 |
17 | **Usage:**
18 | ```bash
19 | python infinity-arcade/examples/lemonade_client_integration_example.py
20 | ```
21 |
22 | This example uses the `Qwen3-0.6B-GGUF` model as it's lightweight and good for testing. You can modify the `required_model` variable to use different models as needed.
23 |
24 | ## Running Examples
25 |
26 | All examples are standalone Python scripts that can be run directly. They include proper error handling and progress reporting, making them suitable for:
27 |
28 | - Learning how to integrate LemonadeClient
29 | - Testing your lemonade-server setup
30 | - Starting point for your own applications
31 | - CI/CD pipeline validation
32 |
33 | ## Adding New Examples
34 |
35 | When adding new examples:
36 |
37 | 1. Include comprehensive docstrings and comments
38 | 2. Add proper error handling with informative messages
39 | 3. Use realistic model names and parameters
40 | 4. Include progress reporting for long-running operations
41 | 5. Update this README with a description of the new example
42 |
--------------------------------------------------------------------------------
/.github/workflows/build-arcade-exe.yml:
--------------------------------------------------------------------------------
1 | name: Build Infinity Arcade Executable
2 |
3 | on:
4 | push:
5 | branches: [ main, develop ]
6 | paths:
7 | - '**'
8 | - '.github/workflows/build-arcade-exe.yml'
9 | pull_request:
10 | branches: [ main ]
11 | paths:
12 | - '**'
13 | release:
14 | types: [published]
15 | workflow_dispatch:
16 | inputs:
17 | version:
18 | description: 'Version to build (e.g., 0.1.0)'
19 | required: false
20 | default: '0.1.0'
21 |
22 | jobs:
23 | build-executable:
24 | runs-on: windows-latest
25 |
26 | steps:
27 | - name: Checkout repository
28 | uses: actions/checkout@v4
29 |
30 | - name: Set up Python
31 | uses: actions/setup-python@v4
32 | with:
33 | python-version: '3.11'
34 |
35 | - name: Cache Python dependencies
36 | uses: actions/cache@v3
37 | with:
38 | path: ~/.cache/pip
39 | key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
40 | restore-keys: |
41 | ${{ runner.os }}-pip-
42 |
43 | - name: Install dependencies
44 | run: |
45 | python -m pip install --upgrade pip
46 | python -m pip install -e .
47 | python -m pip install pyinstaller
48 |
49 | - name: Build executable with PyInstaller
50 | run: |
51 | python -m PyInstaller infinity_arcade.spec
52 | shell: pwsh
53 |
54 | - name: Verify build outputs
55 | run: |
56 | if (-not (Test-Path "dist\InfinityArcade.exe")) {
57 | Write-Error "Executable not found"
58 | exit 1
59 | }
60 |
61 | # Show file information
62 | $exe = Get-Item "dist\InfinityArcade.exe"
63 |
64 | Write-Host "Build artifact:"
65 | Write-Host " - Executable: $($exe.Name) ($([math]::Round($exe.Length / 1MB, 2)) MB)"
66 | Write-Host " - Location: $($exe.FullName)"
67 | shell: pwsh
68 |
69 | - name: Upload executable artifact
70 | uses: actions/upload-artifact@v4
71 | with:
72 | name: infinity-arcade-exe
73 | path: dist/InfinityArcade.exe
74 | retention-days: 30
75 |
76 | - name: Upload to release (if release)
77 | if: github.event_name == 'release'
78 | uses: actions/upload-release-asset@v1
79 | env:
80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
81 | with:
82 | upload_url: ${{ github.event.release.upload_url }}
83 | asset_path: dist/InfinityArcade.exe
84 | asset_name: InfinityArcade.exe
85 | asset_content_type: application/octet-stream
86 |
--------------------------------------------------------------------------------
/examples/lemonade_client_integration_example.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | LemonadeClient Integration Example
4 |
5 | Complete example of integrating LemonadeClient into an application.
6 | """
7 |
8 | import asyncio
9 | from openai import AsyncOpenAI
10 |
11 | from lemonade_client import LemonadeClient
12 |
13 |
14 | # Run everything in an async context
15 | async def main():
16 | client = LemonadeClient()
17 | required_model = "Qwen3-0.6B-GGUF"
18 |
19 | # Check installation
20 | version_info = await client.check_lemonade_server_version()
21 | if not version_info["installed"] or not version_info["compatible"]:
22 | print("Installing lemonade-server...")
23 | result = await client.download_and_install_lemonade_server()
24 | if not result["success"]:
25 | raise Exception(f"Installation failed: {result['message']}")
26 | client.refresh_environment()
27 | client.reset_server_state()
28 |
29 | # Start server
30 | if not await client.check_lemonade_server_running():
31 | print("Starting server...")
32 | result = await client.start_lemonade_server()
33 | if not result["success"]:
34 | raise Exception(f"Server start failed: {result['message']}")
35 | await asyncio.sleep(3) # Wait for startup
36 |
37 | # Verify API
38 | if not await client.check_lemonade_server_api():
39 | raise Exception("Server API not responding")
40 |
41 | # Setup model
42 | model_status = await client.check_model_installed(required_model)
43 | if not model_status["installed"]:
44 | print(f"Installing model {required_model}...")
45 | result = await client.install_model(required_model)
46 | if not result["success"]:
47 | raise Exception(f"Model installation failed: {result['message']}")
48 |
49 | load_status = await client.check_model_loaded(required_model)
50 | if not load_status["loaded"]:
51 | print(f"Loading model {required_model}...")
52 | result = await client.load_model(required_model)
53 | if not result["success"]:
54 | raise Exception(f"Model loading failed: {result['message']}")
55 |
56 | print(f"lemonade-server ready at {client.url}")
57 |
58 | # Make a chat completion request using OpenAI library
59 | openai_client = AsyncOpenAI(
60 | base_url=f"{client.url}/api/v1",
61 | api_key="dummy", # lemonade-server doesn't require a real API key
62 | )
63 |
64 | response = await openai_client.chat.completions.create(
65 | model=required_model,
66 | messages=[
67 | {"role": "user", "content": "Hello! Please respond with 'Hello there!'"}
68 | ],
69 | max_tokens=50,
70 | temperature=0.1,
71 | )
72 |
73 | print(f"Response: {response.choices[0].message.content}")
74 |
75 |
76 | asyncio.run(main())
77 |
--------------------------------------------------------------------------------
/test_build_setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | Test script to verify the executable build process works
4 | """
5 |
6 | import subprocess
7 | import sys
8 | from pathlib import Path
9 |
10 |
11 | def test_dependencies():
12 | """Test that all required dependencies are available."""
13 | print("Testing dependencies...")
14 |
15 | # Test Python modules
16 | required_modules = [
17 | "setuptools",
18 | "fastapi",
19 | "uvicorn",
20 | "pygame",
21 | "httpx",
22 | "jinja2",
23 | ]
24 |
25 | for module in required_modules:
26 | try:
27 | __import__(module)
28 | print(f" ✓ {module}")
29 | except ImportError:
30 | print(f" ✗ {module} (missing)")
31 | return False
32 |
33 | return True
34 |
35 |
36 | def test_build_tools():
37 | """Test that build tools are available."""
38 | print("\nTesting build tools...")
39 |
40 | # Test PyInstaller
41 | try:
42 | subprocess.run(
43 | [sys.executable, "-m", "PyInstaller", "--version"],
44 | check=True,
45 | capture_output=True,
46 | )
47 | print(" ✓ PyInstaller")
48 | except (subprocess.CalledProcessError, FileNotFoundError):
49 | print(" ✗ PyInstaller (install with: pip install pyinstaller)")
50 | return False
51 |
52 | return True
53 |
54 |
55 | def test_files():
56 | """Test that all required files exist."""
57 | print("\nTesting required files...")
58 |
59 | required_files = [
60 | "setup.py",
61 | "infinity_arcade.spec",
62 | "build_exe.ps1",
63 | "src/infinity_arcade/__init__.py",
64 | "src/infinity_arcade/main.py",
65 | "src/infinity_arcade/cli.py",
66 | ]
67 |
68 | for file_path in required_files:
69 | if Path(file_path).exists():
70 | print(f" ✓ {file_path}")
71 | else:
72 | print(f" ✗ {file_path} (missing)")
73 | return False
74 |
75 | return True
76 |
77 |
78 | def main():
79 | """Run all tests."""
80 | print("Infinity Arcade Executable Build Test")
81 | print("=" * 38)
82 |
83 | success = True
84 |
85 | success &= test_dependencies()
86 | success &= test_build_tools()
87 | success &= test_files()
88 |
89 | print("\n" + "=" * 38)
90 | if success:
91 | print("✓ All tests passed! Ready to build executable.")
92 | print("\nTo build the executable, run:")
93 | print(" PowerShell: .\\build_exe.ps1")
94 | print(" Python: python -m PyInstaller infinity_arcade.spec")
95 | else:
96 | print("✗ Some tests failed. Please fix the issues above.")
97 | return 1
98 |
99 | return 0
100 |
101 |
102 | if __name__ == "__main__":
103 | sys.exit(main())
104 |
105 | # Copyright (c) 2025 AMD
106 |
--------------------------------------------------------------------------------
/src/infinity_arcade/arcade_games.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import json
3 | from pathlib import Path
4 | from typing import Dict
5 |
6 | logger = logging.getLogger("infinity_arcade.main")
7 |
8 |
9 | class ArcadeGames:
10 | """
11 | Game storage and metadata manager.
12 |
13 | This class is intentionally free of LLM logic and process execution logic.
14 | It only knows where games live on disk, how to persist metadata, and which
15 | games are built-in.
16 | """
17 |
18 | def __init__(self):
19 |
20 | # Global state
21 | self.games_dir = Path.home() / ".infinity-arcade" / "games"
22 | self.game_metadata: Dict[str, Dict] = {}
23 |
24 | # Ensure games directory exists
25 | self.games_dir.mkdir(parents=True, exist_ok=True)
26 |
27 | # Load existing game metadata
28 | self.metadata_file = self.games_dir / "metadata.json"
29 | if self.metadata_file.exists():
30 | try:
31 | with open(self.metadata_file, "r", encoding="utf-8") as metadata_file:
32 | self.game_metadata = json.load(metadata_file)
33 | except Exception:
34 | self.game_metadata = {}
35 |
36 | # Built-in games configuration
37 | self.builtin_games = {
38 | "builtin_snake": {
39 | "title": "Dynamic Snake",
40 | "created": 0, # Special marker for built-in games
41 | "prompt": "Snake but the food moves around",
42 | "builtin": True,
43 | "file": "snake_moving_food.py",
44 | },
45 | "builtin_invaders": {
46 | "title": "Rainbow Space Invaders",
47 | "created": 0, # Special marker for built-in games
48 | "prompt": "Space invaders with rainbow colors",
49 | "builtin": True,
50 | "file": "rainbow_space_invaders.py",
51 | },
52 | }
53 |
54 | # Add built-in games to metadata if not already present
55 | for game_id, game_data in self.builtin_games.items():
56 | if game_id not in self.game_metadata:
57 | self.game_metadata[game_id] = game_data.copy()
58 |
59 | def save_metadata(self):
60 | """Save game metadata to disk."""
61 | try:
62 | with open(self.metadata_file, "w", encoding="utf-8") as f:
63 | json.dump(self.game_metadata, f, indent=2)
64 | except Exception as e:
65 | print(f"Error saving metadata: {e}")
66 |
67 | # Storage helpers
68 | def save_game_file(self, game_id: str, code: str) -> Path:
69 | game_file = self.games_dir / f"{game_id}.py"
70 | with open(game_file, "w", encoding="utf-8") as f:
71 | f.write(code)
72 | return game_file
73 |
74 | def read_game_file(self, game_id: str) -> str:
75 | game_file = self.games_dir / f"{game_id}.py"
76 | with open(game_file, "r", encoding="utf-8") as f:
77 | return f.read()
78 |
--------------------------------------------------------------------------------
/prompt.md:
--------------------------------------------------------------------------------
1 | You are a great web dev. I want you to create an application from scratch called Infinity Arcade. This app is a python FastAPI server that presents an html+javascript GUI in a web browser. LLMs are served to this app by Lemonade Server via OpenAI chat/completions API.
2 |
3 | Infinity Arcade will be a cross between a ChatGPT-like interface and the concept of a game emulator. Except it wont emulate any games. Instead, Infinity Arcade will use an LLM to generate the game in response to a user's prompt, then start the game.
4 |
5 | Codegen rules (for the system prompt): Games should be coded in Python using the pygame library, in a single Python code block, with absolutely no images or other external files used.
6 |
7 | User journey:
8 | 1. User obtains Infinity Arcade by cloning this repo and running a setup.py.
9 | 1. User opens Infinity Arcade by running "infinity-arcade" in their activated python environment. This should start the FastAPI server and open the GUI in a browser.
10 | 1. User is presented with a html+js+css UI that includes:
11 | - a prompt entry box at the bottom. it should have text entry, a "send" button, and a model selection dropdown. Similar to any popular LLM app. The "send" button should be labeled "create game", not "send".
12 | - a "library" of generated games (game titles in rounded rectangles, like a grid of apps, but dont rely on any image assets).
13 | - a "side-car" on the right to show the LLM's output in real time
14 | - a status icon in the top-right to show if Lemonade Server is running or not. Provide a "Get Lemonade" link if it is not running, show a glowing lemon emoji if it is running.
15 | - At the top center, there should be "Infinity Arcade" in some nice pixel/8bit/ASCII art.
16 | - DO NOT USE IMAGE ASSETS FOR ANY OF THIS. I don't want to have a lot of files in this project at this time.
17 | - Use a dark color theme common to arcades and emulators for this UI. It does not need to look like the typical Lemonade styling.
18 | 1. User enters a prompt describing a game, like "I want to play snake, but the food should move", and presses enter (or clicks a send button).
19 | 1. The LLM streams output into a side-car, and the user can watch this if they like while they wait. The main UI should show a "spinner" indicating progress is being made. The spinner should have status updates like "writing code".
20 | 1. Once code generation is done, the app should do the following:
21 | 1. extract the Python code block from the LLM response.
22 | 1. save the Python code block to a .py file with a unique name, representing a new game.
23 | 1. add this new game as an icon to the "library" described in the UI above. clicking this icon should launch the game.
24 | 1. launch the game by running the .py file using the project's sys.executable.
25 | 1. show a status in the main UI like "game running" and don't let the user launch any new games until they close the prior game.
26 | 1. The user can start any game from their library by clicking the icon made in the previous step.
27 | - Each icon should have an on-hover "X" button, which when clicked opens a "are you sure" dialog to delete the game. Deletion should remove the python file for that game, and the icon from the library.
28 |
29 |
--------------------------------------------------------------------------------
/.github/workflows/test_lemonade_client.yml:
--------------------------------------------------------------------------------
1 | name: Run LemonadeClient Tests
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | test:
11 | runs-on: ${{ matrix.os }}
12 | strategy:
13 | fail-fast: false
14 | matrix:
15 | include:
16 | # Linux with source installation
17 | - os: ubuntu-latest
18 | install-type: 'source'
19 | test-name: 'Linux Source'
20 |
21 | # Windows with source installation
22 | - os: windows-latest
23 | install-type: 'source'
24 | test-name: 'Windows Source'
25 |
26 | name: ${{ matrix.test-name }}
27 |
28 | steps:
29 | - name: Checkout code
30 | uses: actions/checkout@v4
31 |
32 | - name: Set up Python 3.10
33 | uses: actions/setup-python@v4
34 | with:
35 | python-version: '3.10'
36 |
37 | - name: Install base dependencies
38 | run: |
39 | python -m pip install --upgrade pip
40 |
41 | - name: Install source dependencies
42 | run: |
43 | pip install -e .
44 |
45 | - name: Run unit tests
46 | run: |
47 | python test/lemonade_client_unit.py
48 |
49 | - name: Download and install lemonade-server (Windows)
50 | if: runner.os == 'Windows'
51 | shell: powershell
52 | run: |
53 | Write-Host "Downloading lemonade-server MSI installer..."
54 | Invoke-WebRequest -Uri "https://github.com/lemonade-sdk/lemonade/releases/latest/download/lemonade-server-minimal.msi" -OutFile "lemonade-server-minimal.msi"
55 | Write-Host "Installing lemonade-server to C:\lemonade-server..."
56 | Start-Process msiexec.exe -ArgumentList "/i", "lemonade-server-minimal.msi", "/qn", "/norestart", "INSTALLDIR=C:\lemonade-server" -Wait
57 | Write-Host "Adding lemonade-server to PATH..."
58 | Add-Content -Path $env:GITHUB_PATH -Value "C:\lemonade-server\bin"
59 | Write-Host "lemonade-server installation complete"
60 |
61 | - name: Download and install lemonade-server (Linux)
62 | if: runner.os == 'Linux'
63 | shell: bash
64 | run: |
65 | echo "Getting latest release info..."
66 | # Get the latest release tag and find the .deb asset
67 | LATEST_RELEASE=$(curl -s https://api.github.com/repos/lemonade-sdk/lemonade/releases/latest)
68 | DEB_URL=$(echo "$LATEST_RELEASE" | grep -o 'https://github.com/lemonade-sdk/lemonade/releases/download/[^"]*\.deb' | head -1)
69 |
70 | if [ -z "$DEB_URL" ]; then
71 | echo "Error: Could not find .deb package in latest release"
72 | echo "Skipping lemonade-server installation for Linux tests"
73 | else
74 | echo "Downloading lemonade-server .deb package from: $DEB_URL"
75 | curl -L -o lemonade-server.deb "$DEB_URL"
76 | echo "Installing lemonade-server..."
77 | sudo dpkg -i lemonade-server.deb || sudo apt-get install -f -y
78 | echo "lemonade-server installation complete"
79 | fi
80 |
81 | - name: Run integration tests
82 | run: |
83 | python test/lemonade_client_integration.py
84 | env:
85 | PYTHONUNBUFFERED: 1
86 |
87 | - name: Test integration example
88 | run: |
89 | python examples/lemonade_client_integration_example.py
90 | env:
91 | PYTHONUNBUFFERED: 1
--------------------------------------------------------------------------------
/infinity_arcade.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python ; coding: utf-8 -*-
2 |
3 | import os
4 | from pathlib import Path
5 |
6 | # Get the base directory - use current working directory instead of __file__
7 | base_dir = Path(os.getcwd())
8 |
9 | def find_pygame_binaries():
10 | """Find pygame DLLs automatically - works in any environment."""
11 | binaries = []
12 | try:
13 | import pygame
14 | pygame_dir = Path(pygame.__file__).parent
15 |
16 | # SDL2 DLL names that pygame needs
17 | required_dlls = [
18 | 'SDL2.dll',
19 | 'SDL2_image.dll',
20 | 'SDL2_mixer.dll',
21 | 'SDL2_ttf.dll'
22 | ]
23 |
24 | # Find all DLLs in pygame directory
25 | for dll_file in pygame_dir.glob('*.dll'):
26 | binaries.append((str(dll_file), '.'))
27 | print(f"Including pygame DLL: {dll_file.name}")
28 |
29 | print(f"Found {len(binaries)} pygame DLLs")
30 | return binaries
31 |
32 | except ImportError:
33 | print("Warning: pygame not found, no DLLs will be included")
34 | return []
35 |
36 | # Get pygame DLLs automatically
37 | pygame_binaries = find_pygame_binaries()
38 |
39 | a = Analysis(
40 | ['src/infinity_arcade/main.py'],
41 | pathex=[str(base_dir)],
42 | binaries=pygame_binaries,
43 | datas=[
44 | # Include static files and templates
45 | ('src/infinity_arcade/static', 'infinity_arcade/static'),
46 | ('src/infinity_arcade/templates', 'infinity_arcade/templates'),
47 | ('src/infinity_arcade/builtin_games', 'infinity_arcade/builtin_games'),
48 | ],
49 | hiddenimports=[
50 | 'uvicorn.lifespan.on',
51 | 'uvicorn.lifespan.off',
52 | 'uvicorn.protocols.websockets.auto',
53 | 'uvicorn.protocols.websockets.websockets_impl',
54 | 'uvicorn.protocols.http.auto',
55 | 'uvicorn.protocols.http.h11_impl',
56 | 'uvicorn.loops.auto',
57 | 'uvicorn.loops.asyncio',
58 | 'fastapi',
59 | 'fastapi.routing',
60 | 'fastapi.staticfiles',
61 | 'fastapi.templating',
62 | 'jinja2',
63 | 'pygame',
64 | 'pygame._sdl2',
65 | 'pygame._sdl2.audio',
66 | 'pygame._sdl2.controller',
67 | 'pygame._sdl2.mixer',
68 | 'pygame._sdl2.sdl2',
69 | 'pygame._sdl2.touch',
70 | 'pygame._sdl2.video',
71 | 'httpx',
72 | 'httpx._client',
73 | 'httpx._config',
74 | 'httpx._models',
75 | 'httpx._types',
76 | 'httpx._auth',
77 | 'httpx._exceptions',
78 | 'httpcore',
79 | 'httpcore._sync',
80 | 'httpcore._async',
81 | 'h11',
82 | 'h2',
83 | 'certifi',
84 | 'charset_normalizer',
85 | 'idna',
86 | 'sniffio',
87 | ],
88 | hookspath=[],
89 | hooksconfig={},
90 | runtime_hooks=['hook_pygame.py'],
91 | excludes=['SDL3'], # Explicitly exclude SDL3 to avoid conflicts
92 | win_no_prefer_redirects=False,
93 | win_private_assemblies=False,
94 | cipher=None,
95 | noarchive=False,
96 | )
97 |
98 | pyz = PYZ(a.pure, a.zipped_data, cipher=None)
99 |
100 | exe = EXE(
101 | pyz,
102 | a.scripts,
103 | a.binaries,
104 | a.zipfiles,
105 | a.datas,
106 | [],
107 | name='InfinityArcade',
108 | debug=False,
109 | bootloader_ignore_signals=False,
110 | strip=False,
111 | upx=True,
112 | upx_exclude=[],
113 | runtime_tmpdir=None,
114 | console=True, # Show console window for debugging
115 | disable_windowed_traceback=False,
116 | argv_emulation=False,
117 | target_arch=None,
118 | codesign_identity=None,
119 | entitlements_file=None,
120 | icon='img/icon.ico'
121 | )
122 |
123 | # Copyright (c) 2025 AMD
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[codz]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # Lemonade Arcade specific
30 | games/
31 | *.py.bak
32 |
33 | # PyInstaller
34 | # Usually these files are written by a python script from a template
35 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
36 | *.manifest
37 | *.spec
38 |
39 | # Installer logs
40 | pip-log.txt
41 | pip-delete-this-directory.txt
42 |
43 | # Unit test / coverage reports
44 | htmlcov/
45 | .tox/
46 | .nox/
47 | .coverage
48 | .coverage.*
49 | .cache
50 | nosetests.xml
51 | coverage.xml
52 | *.cover
53 | *.py.cover
54 | .hypothesis/
55 | .pytest_cache/
56 | cover/
57 |
58 | # Translations
59 | *.mo
60 | *.pot
61 |
62 | # Django stuff:
63 | *.log
64 | local_settings.py
65 | db.sqlite3
66 | db.sqlite3-journal
67 |
68 | # Flask stuff:
69 | instance/
70 | .webassets-cache
71 |
72 | # Scrapy stuff:
73 | .scrapy
74 |
75 | # Sphinx documentation
76 | docs/_build/
77 |
78 | # PyBuilder
79 | .pybuilder/
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | # For a library or package, you might want to ignore these files since the code is
91 | # intended to run in multiple environments; otherwise, check them in:
92 | # .python-version
93 |
94 | # pipenv
95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
98 | # install all needed dependencies.
99 | #Pipfile.lock
100 |
101 | # UV
102 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
103 | # This is especially recommended for binary packages to ensure reproducibility, and is more
104 | # commonly ignored for libraries.
105 | #uv.lock
106 |
107 | # poetry
108 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
109 | # This is especially recommended for binary packages to ensure reproducibility, and is more
110 | # commonly ignored for libraries.
111 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
112 | #poetry.lock
113 | #poetry.toml
114 |
115 | # pdm
116 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
117 | # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
118 | # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
119 | #pdm.lock
120 | #pdm.toml
121 | .pdm-python
122 | .pdm-build/
123 |
124 | # pixi
125 | # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
126 | #pixi.lock
127 | # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
128 | # in the .venv directory. It is recommended not to include this directory in version control.
129 | .pixi
130 |
131 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
132 | __pypackages__/
133 |
134 | # Celery stuff
135 | celerybeat-schedule
136 | celerybeat.pid
137 |
138 | # SageMath parsed files
139 | *.sage.py
140 |
141 | # Environments
142 | .env
143 | .envrc
144 | .venv
145 | env/
146 | venv/
147 | ENV/
148 | env.bak/
149 | venv.bak/
150 |
151 | # Spyder project settings
152 | .spyderproject
153 | .spyproject
154 |
155 | # Rope project settings
156 | .ropeproject
157 |
158 | # mkdocs documentation
159 | /site
160 |
161 | # mypy
162 | .mypy_cache/
163 | .dmypy.json
164 | dmypy.json
165 |
166 | # Pyre type checker
167 | .pyre/
168 |
169 | # pytype static type analyzer
170 | .pytype/
171 |
172 | # Cython debug symbols
173 | cython_debug/
174 |
175 | # PyCharm
176 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
177 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
178 | # and can be added to the global gitignore or merged into this file. For a more nuclear
179 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
180 | #.idea/
181 |
182 | # Abstra
183 | # Abstra is an AI-powered process automation framework.
184 | # Ignore directories containing user credentials, local state, and settings.
185 | # Learn more at https://abstra.io/docs
186 | .abstra/
187 |
188 | # Visual Studio Code
189 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
190 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
191 | # and can be added to the global gitignore or merged into this file. However, if you prefer,
192 | # you could uncomment the following to ignore the entire vscode folder
193 | # .vscode/
194 |
195 | # Ruff stuff:
196 | .ruff_cache/
197 |
198 | # PyPI configuration file
199 | .pypirc
200 |
201 | # Cursor
202 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
203 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
204 | # refer to https://docs.cursor.com/context/ignore-files
205 | .cursorignore
206 | .cursorindexingignore
207 |
208 | # Marimo
209 | marimo/_static/
210 | marimo/_lsp/
211 | __marimo__/
212 |
--------------------------------------------------------------------------------
/src/infinity_arcade/game_launcher.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import subprocess
3 | import sys
4 | import time
5 | from pathlib import Path
6 | from typing import Dict, Tuple
7 |
8 |
9 | logger = logging.getLogger("infinity_arcade.main")
10 |
11 |
12 | class GameLauncher:
13 | """Handles game process execution and lifecycle management."""
14 |
15 | def __init__(self) -> None:
16 | self.running_games: Dict[str, subprocess.Popen] = {}
17 |
18 | def launch_game_process(self, game_file: Path, game_id: str) -> Tuple[bool, str]:
19 | """Launch a game process and return (success, error_message).
20 |
21 | This mirrors the previous behavior used in ArcadeGames: if the process
22 | exits within ~2 seconds, treat that as a failure for pygame-based games
23 | and capture stderr to present a useful error message.
24 | """
25 | try:
26 | if getattr(sys, "frozen", False):
27 | cmd = [sys.executable, str(game_file)]
28 | logger.debug(f"PyInstaller mode - Launching: {' '.join(cmd)}")
29 | else:
30 | cmd = [sys.executable, str(game_file)]
31 | logger.debug(f"Development mode - Launching: {' '.join(cmd)}")
32 |
33 | # pylint: disable=consider-using-with
34 | process = subprocess.Popen(
35 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
36 | )
37 |
38 | start_time = time.time()
39 | logger.debug(f"Game {game_id} subprocess started with PID {process.pid}")
40 |
41 | try:
42 | stdout, stderr = process.communicate(timeout=2)
43 | end_time = time.time()
44 | duration = end_time - start_time
45 | logger.debug(
46 | f"Game {game_id} subprocess (PID {process.pid}) EXITED after {duration:.3f} "
47 | f"seconds with return code {process.returncode}"
48 | )
49 |
50 | # Filter out noisy warnings from stderr to get actual errors
51 | stderr_lines = stderr.strip().split("\n") if stderr else []
52 | actual_errors = []
53 | for line in stderr_lines:
54 | if any(
55 | skip_phrase in line
56 | for skip_phrase in [
57 | "UserWarning",
58 | "pkg_resources is deprecated",
59 | "from pkg_resources import",
60 | "pygame community",
61 | "https://www.pygame.org",
62 | ]
63 | ):
64 | continue
65 | if line.strip() and any(
66 | indicator in line
67 | for indicator in [
68 | "Error",
69 | "Exception",
70 | "Traceback",
71 | 'File "',
72 | "line ",
73 | "NameError",
74 | "ImportError",
75 | "SyntaxError",
76 | "AttributeError",
77 | "TypeError",
78 | "ValueError",
79 | ]
80 | ):
81 | actual_errors.append(line)
82 |
83 | filtered_stderr = "\n".join(actual_errors).strip()
84 |
85 | if process.returncode != 0:
86 | error_msg = (
87 | filtered_stderr
88 | if filtered_stderr
89 | else f"Game exited with code {process.returncode} but no error message was captured"
90 | )
91 | logger.error(
92 | f"Game {game_id} failed with return code {process.returncode}: {error_msg}"
93 | )
94 | if stdout:
95 | print("STDOUT:")
96 | print(stdout)
97 | if stderr:
98 | print("STDERR:")
99 | print(stderr)
100 | if not stdout and not stderr:
101 | print("No output captured")
102 | print("=" * 60)
103 | return False, error_msg
104 |
105 | # Success path for quick-and-clean exit (no time-based failure)
106 | logger.debug(
107 | f"Game {game_id} exited quickly with return code 0; treating as successful completion"
108 | )
109 | if stdout:
110 | print("STDOUT:")
111 | print(stdout)
112 | if stderr:
113 | print("STDERR:")
114 | print(stderr)
115 | if not stdout and not stderr:
116 | print("No output captured")
117 | print("=" * 60)
118 | return True, "Game exited successfully"
119 | except subprocess.TimeoutExpired:
120 | # Timeout is good - means the game is still running
121 | end_time = time.time()
122 | duration = end_time - start_time
123 | self.running_games[game_id] = process
124 | logger.debug(
125 | f"Game {game_id} subprocess (PID {process.pid}) STILL RUNNING after {duration:.3f} seconds timeout"
126 | )
127 | return True, "Game launched successfully"
128 | except Exception as e:
129 | logger.error(f"Error launching game {game_id}: {e}")
130 | return False, str(e)
131 |
132 | def stop_game(self, game_id: str) -> None:
133 | if game_id in self.running_games:
134 | try:
135 | process = self.running_games[game_id]
136 | logger.debug(
137 | f"MANUALLY STOPPING game {game_id} subprocess (PID {process.pid})"
138 | )
139 | process.terminate()
140 | try:
141 | process.wait(timeout=5)
142 | logger.debug(
143 | f"Game {game_id} subprocess (PID {process.pid}) terminated gracefully"
144 | )
145 | except subprocess.TimeoutExpired:
146 | logger.debug(
147 | f"Game {game_id} subprocess (PID {process.pid}) did not terminate gracefully, killing..."
148 | )
149 | process.kill()
150 | logger.debug(
151 | f"Game {game_id} subprocess (PID {process.pid}) killed"
152 | )
153 | except Exception as e:
154 | print(f"Error stopping game {game_id}: {e}")
155 | finally:
156 | del self.running_games[game_id]
157 |
158 | def cleanup_finished_games(self) -> None:
159 | finished: list[str] = []
160 | for game_id, process in self.running_games.items():
161 | if process.poll() is not None:
162 | return_code = process.returncode
163 | logger.debug(
164 | f"Game {game_id} subprocess (PID {process.pid}) FINISHED with return code {return_code} - cleaning up"
165 | )
166 | finished.append(game_id)
167 | for game_id in finished:
168 | del self.running_games[game_id]
169 |
--------------------------------------------------------------------------------
/src/infinity_arcade/builtin_games/snake_moving_food.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | Dynamic Snake Game - Snake but the food moves around
4 | Built-in game for Infinity Arcade
5 | """
6 |
7 | import pygame
8 | import random
9 | import sys
10 | import math
11 |
12 | # Initialize Pygame
13 | pygame.init()
14 |
15 | # Game constants
16 | WINDOW_WIDTH = 800
17 | WINDOW_HEIGHT = 600
18 | GRID_SIZE = 20
19 | GRID_WIDTH = WINDOW_WIDTH // GRID_SIZE
20 | GRID_HEIGHT = WINDOW_HEIGHT // GRID_SIZE
21 |
22 | # Colors
23 | BLACK = (0, 0, 0)
24 | GREEN = (0, 255, 0)
25 | RED = (255, 0, 0)
26 | WHITE = (255, 255, 255)
27 | YELLOW = (255, 255, 0)
28 | BLUE = (0, 100, 255)
29 |
30 |
31 | class Snake:
32 | def __init__(self):
33 | self.body = [(GRID_WIDTH // 2, GRID_HEIGHT // 2)]
34 | self.direction = (1, 0)
35 | self.grow_next = False
36 |
37 | def move(self):
38 | head_x, head_y = self.body[0]
39 | dx, dy = self.direction
40 | new_head = (head_x + dx, head_y + dy)
41 |
42 | # Check wall collision
43 | if (
44 | new_head[0] < 0
45 | or new_head[0] >= GRID_WIDTH
46 | or new_head[1] < 0
47 | or new_head[1] >= GRID_HEIGHT
48 | ):
49 | return False
50 |
51 | # Check self collision
52 | if new_head in self.body:
53 | return False
54 |
55 | self.body.insert(0, new_head)
56 |
57 | if not self.grow_next:
58 | self.body.pop()
59 | else:
60 | self.grow_next = False
61 |
62 | return True
63 |
64 | def grow(self):
65 | self.grow_next = True
66 |
67 | def draw(self, screen):
68 | for i, segment in enumerate(self.body):
69 | x, y = segment
70 | rect = pygame.Rect(x * GRID_SIZE, y * GRID_SIZE, GRID_SIZE, GRID_SIZE)
71 |
72 | if i == 0: # Head
73 | pygame.draw.rect(screen, GREEN, rect)
74 | pygame.draw.rect(screen, WHITE, rect, 2)
75 | else: # Body
76 | pygame.draw.rect(screen, GREEN, rect)
77 | pygame.draw.rect(screen, BLACK, rect, 1)
78 |
79 |
80 | class MovingFood:
81 | def __init__(self):
82 | self.position = self.random_position()
83 | self.direction = self.random_direction()
84 | self.speed = 0.1 # Speed of movement
85 | self.float_pos = [float(self.position[0]), float(self.position[1])]
86 | self.change_direction_timer = 0
87 | self.pulse_time = 0
88 |
89 | def random_position(self):
90 | return (random.randint(0, GRID_WIDTH - 1), random.randint(0, GRID_HEIGHT - 1))
91 |
92 | def random_direction(self):
93 | angle = random.uniform(0, 2 * math.pi)
94 | return (math.cos(angle), math.sin(angle))
95 |
96 | def update(self, snake_body):
97 | self.pulse_time += 0.2
98 | self.change_direction_timer += 1
99 |
100 | # Change direction randomly every 2-4 seconds
101 | if self.change_direction_timer > random.randint(120, 240):
102 | self.direction = self.random_direction()
103 | self.change_direction_timer = 0
104 |
105 | # Move the food
106 | self.float_pos[0] += self.direction[0] * self.speed
107 | self.float_pos[1] += self.direction[1] * self.speed
108 |
109 | # Bounce off walls
110 | if self.float_pos[0] <= 0 or self.float_pos[0] >= GRID_WIDTH - 1:
111 | self.direction = (-self.direction[0], self.direction[1])
112 | self.float_pos[0] = max(1, min(GRID_WIDTH - 2, self.float_pos[0]))
113 |
114 | if self.float_pos[1] <= 0 or self.float_pos[1] >= GRID_HEIGHT - 1:
115 | self.direction = (self.direction[0], -self.direction[1])
116 | self.float_pos[1] = max(1, min(GRID_HEIGHT - 2, self.float_pos[1]))
117 |
118 | # Update grid position
119 | self.position = (int(self.float_pos[0]), int(self.float_pos[1]))
120 |
121 | # Avoid spawning on snake
122 | if self.position in snake_body:
123 | self.direction = self.random_direction()
124 |
125 | def draw(self, screen):
126 | x, y = self.position
127 | rect = pygame.Rect(x * GRID_SIZE, y * GRID_SIZE, GRID_SIZE, GRID_SIZE)
128 |
129 | # Pulsing effect
130 | pulse = abs(math.sin(self.pulse_time))
131 | color_intensity = int(200 + 55 * pulse)
132 | color = (color_intensity, 0, 0)
133 |
134 | pygame.draw.rect(screen, color, rect)
135 | pygame.draw.rect(screen, YELLOW, rect, 2)
136 |
137 |
138 | def main():
139 | screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
140 | pygame.display.set_caption("Dynamic Snake - Moving Food Edition")
141 | clock = pygame.time.Clock()
142 | font = pygame.font.Font(None, 36)
143 |
144 | snake = Snake()
145 | food = MovingFood()
146 | score = 0
147 | game_over = False
148 |
149 | while True:
150 | for event in pygame.event.get():
151 | if event.type == pygame.QUIT:
152 | pygame.quit()
153 | sys.exit()
154 |
155 | if event.type == pygame.KEYDOWN:
156 | if game_over:
157 | if event.key == pygame.K_SPACE:
158 | # Restart game
159 | snake = Snake()
160 | food = MovingFood()
161 | score = 0
162 | game_over = False
163 | else:
164 | # Snake controls
165 | if event.key == pygame.K_UP and snake.direction != (0, 1):
166 | snake.direction = (0, -1)
167 | elif event.key == pygame.K_DOWN and snake.direction != (0, -1):
168 | snake.direction = (0, 1)
169 | elif event.key == pygame.K_LEFT and snake.direction != (1, 0):
170 | snake.direction = (-1, 0)
171 | elif event.key == pygame.K_RIGHT and snake.direction != (-1, 0):
172 | snake.direction = (1, 0)
173 |
174 | if not game_over:
175 | # Update food position
176 | food.update(snake.body)
177 |
178 | # Move snake
179 | if not snake.move():
180 | game_over = True
181 |
182 | # Check food collision
183 | if snake.body[0] == food.position:
184 | snake.grow()
185 | score += 10
186 | food = MovingFood() # Create new moving food
187 |
188 | # Draw everything
189 | screen.fill(BLACK)
190 |
191 | if not game_over:
192 | snake.draw(screen)
193 | food.draw(screen)
194 |
195 | # Draw score
196 | score_text = font.render(f"Score: {score}", True, WHITE)
197 | screen.blit(score_text, (10, 10))
198 |
199 | if game_over:
200 | game_over_text = font.render("GAME OVER", True, RED)
201 | restart_text = font.render("Press SPACE to restart", True, WHITE)
202 |
203 | game_over_rect = game_over_text.get_rect(
204 | center=(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2 - 20)
205 | )
206 | restart_rect = restart_text.get_rect(
207 | center=(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2 + 20)
208 | )
209 |
210 | screen.blit(game_over_text, game_over_rect)
211 | screen.blit(restart_text, restart_rect)
212 |
213 | pygame.display.flip()
214 | clock.tick(10) # Snake speed
215 |
216 |
217 | if __name__ == "__main__":
218 | main()
219 |
220 | # Copyright (c) 2025 AMD
221 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ██╗███╗ ██╗███████╗██╗███╗ ██╗██╗████████╗██╗ ██╗
4 | ██║████╗ ██║██╔════╝██║████╗ ██║██║╚══██╔══╝╚██╗ ██╔╝
5 | ██║██╔██╗ ██║█████╗ ██║██╔██╗ ██║██║ ██║ ╚████╔╝
6 | ██║██║╚██╗██║██╔══╝ ██║██║╚██╗██║██║ ██║ ╚██╔╝
7 | ██║██║ ╚████║██║ ██║██║ ╚████║██║ ██║ ██║
8 | ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝
9 |
10 | █████╗ ██████╗ ██████╗ █████╗ ██████╗ ███████╗
11 | ██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔════╝
12 | ███████║██████╔╝██║ ███████║██║ ██║█████╗
13 | ██╔══██║██╔══██╗██║ ██╔══██║██║ ██║██╔══╝
14 | ██║ ██║██║ ██║╚██████╗██║ ██║██████╔╝███████╗
15 | ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═════╝ ╚══════╝
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | Create playable retro-style games with LLMs in minutes! Enter your prompt and your game pops open, it's that simple.
38 |
39 | Push your imagination to the limit, it's 100% free and local.
40 |
41 | 
42 |
43 |
44 | ## Hardware Requirement
45 |
46 | Infinity Arcade will detect the hardware you have available and load a recommended LLM.
47 |
48 | | Configuration | GPU/APU | Memory | Disk Space | LLM |
49 | |---------------|---------|---------|---------|---------|
50 | | **Minimum (CPU)** | Ryzen AI 7000-series chip or newer | 32 GB RAM | 5 GB | [Playable1-GGUF](https://huggingface.co/playable/Playable1-GGUF) |
51 | | **Suggested (iGPU)** | Ryzen AI 300-series chip or newer | 32 GB RAM | 5 GB | [Playable1-GGUF](https://huggingface.co/playable/Playable1-GGUF) |
52 | | **Suggested (dGPU)** | Radeon 7800XT or newer | 16 GB VRAM | 20 GB | Qwen3-Coder-30B |
53 | | **Suggested (APU)** | Strix Halo (Ryzen AI MAX 395) | 64 GB unified memory | 20 GB | Qwen3-Coder-30B |
54 |
55 | ## Quick Start
56 |
57 |
58 | Windows: click this:
59 |
60 |
61 |
62 |
63 |
64 | Linux: click this
65 |
66 |
67 | ## Overview
68 |
69 | Infinity Arcade combines the convenience of a ChatGPT-like interface with the concept of a game emulator. Instead of emulating existing games, it uses LLMs (served by [Lemonade](https://github.com/lemonade-sdk/lemonade)) to generate completely new games based on your prompts, then lets you play them instantly.
70 |
71 | ## Features
72 |
73 | - **Lemonade integration**: automatically connects to Lemonade Server and has access to any Lemonade LLM.
74 | - **AI Game Generation**: Describe a game concept and watch as an LLM creates a playable Python game.
75 | - **Game Library**: All generated games are saved and can be replayed anytime.
76 | - **Easy Management**: View game source code, copy prompts for remixing, and delete games you don't want with a simple click.
77 |
78 | ## Installation
79 |
80 | ### Windows
81 |
82 | Navigate to the [Releases page](https://github.com/lemonade-sdk/infinity-arcade/releases), download the .exe, and get started!
83 |
84 | ### Linux (and Windows Devs)
85 |
86 | From PyPI (recommended):
87 |
88 | ```bash
89 | pip install infinity-arcade
90 | infinity-arcade
91 | ```
92 |
93 | From Source:
94 |
95 | 1. Clone this repository:
96 | ```bash
97 | git clone https://github.com/lemonade-sdk/infinity-arcade
98 | cd infinity-arcade
99 | ```
100 |
101 | 2. Install the package:
102 | ```bash
103 | pip install -e .
104 | ```
105 |
106 | 3. Run it:
107 | ```bash
108 | infinity-arcade
109 | ```
110 |
111 | ## Architecture
112 |
113 | ### Game Generation
114 |
115 | Games are generated with the following constraints:
116 | - Pure Python using the pygame library only.
117 | - No external images, sounds, or asset files.
118 | - Complete and playable with proper game mechanics.
119 | - Proper event handling and game loops.
120 | - Visual appeal using pygame's built-in drawing functions.
121 |
122 | > Note: LLMs are imperfect, and may fail to generate the game you asked for or fail to generate a functioning game at all.
123 |
124 | ### Game Cache
125 |
126 | Games are cached under the `.infinity-arcade` folder in your home directory.
127 |
128 | ```
129 | ~/.infinity-arcade/
130 | └── games/
131 | ├── metadata.json # Game titles and descriptions
132 | ├── abc12345.py # Generated game files
133 | └── xyz67890.py
134 | ```
135 |
136 | ## Troubleshooting
137 |
138 | ### "Server Offline" Status
139 | - Ensure Lemonade Server is running on `http://localhost:8000`.
140 | - Check that you have models installed in Lemonade Server by opening the model manager: http://localhost:8000/#model-management.
141 | - Visit [lemonade-server.ai](https://lemonade-server.ai) for setup instructions.
142 |
143 | ### Game Won't Launch
144 | - Check the generated code for any syntax errors.
145 | - Try regenerating the game with a more specific prompt.
146 |
147 | ### Generation Failures
148 | - Try a simpler game concept.
149 | - Make sure your selected model supports code generation.
150 | - Check the `infinity-arcade` and Lemonade Server logs for errors.
151 |
152 | ## Examples
153 |
154 | Here are some example prompts that work well:
155 |
156 | - **Classic Games**: "pong", "tetris", "pacman maze game", "asteroids"
157 | - **Variations**: "snake but food teleports", "breakout with power-ups", "flappy bird in space"
158 | - **Original Ideas**: "catching falling stars", "color matching puzzle", "maze with moving walls"
159 |
160 | ## Contributing
161 |
162 | Contributions are welcome! Feel free to:
163 | - Share interesting game prompts and results by opening an issue!
164 | - Report bugs or request features via GitHub issues.
165 | - Submit pull requests for improvements.
166 |
167 |
168 | ## License and Attribution
169 |
170 | This project is licensed under the [MIT license](./LICENSE). It was built with Python with ❤️ for the gaming and LLM communities. It is built on the shoulders of many great open source tools, including llama.cpp, Hugging Face Hub, and OpenAI API.
171 |
172 | Most of the code for this project was generated by Claude Sonnet 4.
173 |
174 | ## Maintainer
175 |
176 | This project is maintained by @jeremyfowers.
177 |
178 |
179 |
180 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Infinity Arcade - Make Retro-Style Games in Minutes with AI
7 |
8 |
9 |
10 |
11 |
12 |
13 |
23 |
24 |
25 |
26 |
27 |
██╗███╗ ██╗███████╗██╗███╗ ██╗██╗████████╗██╗ ██╗
28 | ██║████╗ ██║██╔════╝██║████╗ ██║██║╚══██╔══╝╚██╗ ██╔╝
29 | ██║██╔██╗ ██║█████╗ ██║██╔██╗ ██║██║ ██║ ╚████╔╝
30 | ██║██║╚██╗██║██╔══╝ ██║██║╚██╗██║██║ ██║ ╚██╔╝
31 | ██║██║ ╚████║██║ ██║██║ ╚████║██║ ██║ ██║
32 | ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝
33 |
34 | █████╗ ██████╗ ██████╗ █████╗ ██████╗ ███████╗
35 | ██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔════╝
36 | ███████║██████╔╝██║ ███████║██║ ██║█████╗
37 | ██╔══██║██╔══██╗██║ ██╔══██║██║ ██║██╔══╝
38 | ██║ ██║██║ ██║╚██████╗██║ ██║██████╔╝███████╗
39 | ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═════╝ ╚══════╝
40 |
41 |
42 |
43 | Create amazing games with AI
44 |
45 |
46 |
47 | Infinity Arcade uses local LLMs to create retro-style games in minutes.
48 | Build and play new games where the limit is your creativity.
49 |
50 |
51 |
52 |
74 |
75 |
76 |
79 |
80 |
81 |
85 |
86 |
87 |
88 |
🤖
89 |
AI-Powered
90 |
91 | Use AI to relive nostalgic games or generate new ones!
92 |
93 |
94 |
95 |
96 |
⚡
97 |
Instant Gratification
98 |
99 | From idea to playable game in minutes.
100 |
101 |
102 |
103 |
104 |
🕹️
105 |
Game Ready
106 |
107 | Built-in games included. Get inspired.
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
135 |
136 |
137 |
193 |
194 |
195 |
--------------------------------------------------------------------------------
/src/infinity_arcade/game_orchestrator.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import time
4 | from pathlib import Path
5 | from typing import AsyncGenerator
6 |
7 | from infinity_arcade.arcade_games import ArcadeGames
8 | from infinity_arcade.game_launcher import GameLauncher
9 | from infinity_arcade.llm_service import LLMService, ExtractedCode
10 |
11 |
12 | logger = logging.getLogger("infinity_arcade.main")
13 |
14 |
15 | class GameOrchestrator:
16 | """Coordinates storage, launching, and LLM to implement arcade workflows."""
17 |
18 | def __init__(
19 | self, storage: ArcadeGames, launcher: GameLauncher, llm_service: LLMService
20 | ) -> None:
21 | self.storage = storage
22 | self.launcher = launcher
23 | self.llm = llm_service
24 |
25 | def _get_game_file_path(self, game_id: str) -> Path:
26 | if game_id in self.storage.builtin_games:
27 | from infinity_arcade.utils import get_resource_path
28 |
29 | builtin_games_dir = Path(get_resource_path("builtin_games"))
30 | return builtin_games_dir / self.storage.builtin_games[game_id]["file"]
31 | return self.storage.games_dir / f"{game_id}.py"
32 |
33 | async def create_and_launch_game_with_streaming(
34 | self, game_id: str, prompt: str
35 | ) -> AsyncGenerator[str, None]:
36 | # Status: connecting
37 | yield f"data: {json.dumps({'type': 'status', 'message': 'Connecting to LLM...'})}\n\n"
38 | # Status: generating
39 | yield f"data: {json.dumps({'type': 'status', 'message': 'Generating code...'})}\n\n"
40 |
41 | python_code: str | None = None
42 | async for result in self.llm.stream_game_code("create", prompt):
43 | if result is None:
44 | yield f"data: {json.dumps({'type': 'error', 'message': 'Failed to generate code'})}\n\n"
45 | return
46 | elif isinstance(result, ExtractedCode):
47 | python_code = result.code
48 | break
49 | elif isinstance(result, str):
50 | yield f"data: {json.dumps({'type': 'content', 'content': result})}\n\n"
51 |
52 | if not python_code:
53 | error_msg = "Could not extract valid Python code from response"
54 | yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n"
55 | return
56 |
57 | yield f"data: {json.dumps({'type': 'status', 'message': 'Extracting code...'})}\n\n"
58 |
59 | # Generate title
60 | game_title = await self.llm.generate_title(prompt)
61 |
62 | # Save game file and metadata
63 | game_file = self.storage.games_dir / f"{game_id}.py"
64 | game_file.write_text(python_code, encoding="utf-8")
65 | self.storage.game_metadata[game_id] = {
66 | "title": game_title,
67 | "created": time.time(),
68 | "prompt": prompt,
69 | }
70 | self.storage.save_metadata()
71 |
72 | # Launch
73 | launch_message = "Launching game..."
74 | yield f"data: {json.dumps({'type': 'status', 'message': launch_message})}\n\n"
75 | async for item in self.launch_game_with_auto_fix_streaming(game_id, game_title):
76 | yield item
77 |
78 | async def remix_and_launch_game_with_streaming(
79 | self, original_game_id: str, new_game_id: str, remix_prompt: str, new_title: str
80 | ) -> AsyncGenerator[str, None]:
81 | # Read original code
82 | original_file = self._get_game_file_path(original_game_id)
83 | if not original_file.exists():
84 | yield f"data: {json.dumps({'type': 'error', 'message': 'Original game file not found'})}\n\n"
85 | return
86 | original_code = original_file.read_text(encoding="utf-8")
87 |
88 | # Status
89 | yield f"data: {json.dumps({'type': 'status', 'message': 'Remixing code...'})}\n\n"
90 |
91 | remixed_code: str | None = None
92 | async for result in self.llm.stream_game_code(
93 | "remix", original_code, remix_prompt
94 | ):
95 | if result is None:
96 | yield f"data: {json.dumps({'type': 'error', 'message': 'Failed to remix code'})}\n\n"
97 | return
98 | elif isinstance(result, ExtractedCode):
99 | remixed_code = result.code
100 | break
101 | elif isinstance(result, str):
102 | yield f"data: {json.dumps({'type': 'content', 'content': result})}\n\n"
103 |
104 | if not remixed_code:
105 | error_msg = "Could not extract valid Python code from remix response"
106 | yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n"
107 | return
108 |
109 | yield f"data: {json.dumps({'type': 'status', 'message': 'Extracting remixed code...'})}\n\n"
110 |
111 | # Save new game
112 | game_file = self.storage.games_dir / f"{new_game_id}.py"
113 | game_file.write_text(remixed_code, encoding="utf-8")
114 | self.storage.game_metadata[new_game_id] = {
115 | "title": new_title,
116 | "created": time.time(),
117 | "prompt": f"Remix of '{self.storage.game_metadata.get(original_game_id, {}).get('title', 'Untitled Game')}': {remix_prompt}",
118 | }
119 | self.storage.save_metadata()
120 |
121 | # Launch
122 | launch_message = "Launching remixed game..."
123 | yield f"data: {json.dumps({'type': 'status', 'message': launch_message})}\n\n"
124 | async for item in self.launch_game_with_auto_fix_streaming(
125 | new_game_id, new_title
126 | ):
127 | yield item
128 |
129 | async def launch_game_with_auto_fix_streaming(
130 | self, game_id: str, game_title: str, max_retries: int = 1
131 | ) -> AsyncGenerator[str, None]:
132 | retry_count = 0
133 | while retry_count <= max_retries:
134 | game_file = self._get_game_file_path(game_id)
135 | if not game_file.exists():
136 | error_msg = f"Game file not found: {game_file}"
137 | yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n"
138 | return
139 |
140 | success, error_message = self.launcher.launch_game_process(
141 | game_file, game_id
142 | )
143 | if success:
144 | message = f"Game '{game_title}' created and launched successfully!"
145 | complete_data = {
146 | "type": "complete",
147 | "game_id": game_id,
148 | "message": message,
149 | }
150 | yield f"data: {json.dumps(complete_data)}\n\n"
151 | return
152 |
153 | # Retry with LLM fix
154 | if (
155 | retry_count < max_retries
156 | and game_id not in self.storage.builtin_games
157 | and game_id in self.storage.game_metadata
158 | ):
159 | status_msg = "Game hit an error, trying to fix it..."
160 | yield f"data: {json.dumps({'type': 'status', 'message': status_msg})}\n\n"
161 |
162 | error_separator = (
163 | f"\n\n---\n\n# ⚠️ ERROR ENCOUNTERED\n\n"
164 | f"> 🔧 **The generated game encountered an error during launch.** \n"
165 | f"> **Attempting to automatically fix the code...**\n\n"
166 | f"**Error Details:**\n```\n{error_message}\n```\n\n---\n\n"
167 | f"## 🛠️ Fix Attempt:\n\n"
168 | )
169 | yield f"data: {json.dumps({'type': 'content', 'content': error_separator})}\n\n"
170 |
171 | try:
172 | current_code = game_file.read_text(encoding="utf-8")
173 | fixed_code: str | None = None
174 | async for result in self.llm.stream_game_code(
175 | "debug", current_code, error_message
176 | ):
177 | if result is None:
178 | break
179 | elif isinstance(result, ExtractedCode):
180 | fixed_code = result.code
181 | break
182 | elif isinstance(result, str):
183 | yield f"data: {json.dumps({'type': 'content', 'content': result})}\n\n"
184 |
185 | if fixed_code:
186 | game_file.write_text(fixed_code, encoding="utf-8")
187 | retry_count += 1
188 | continue
189 | else:
190 | error_msg = f"Game '{game_title}' failed to launch and could not be automatically fixed: {error_message}"
191 | final_error_content = f"\n\n---\n\n> ❌ **FINAL ERROR** \n> {error_msg}\n\n---\n\n"
192 | yield f"data: {json.dumps({'type': 'content', 'content': final_error_content})}\n\n"
193 | yield f"data: {json.dumps({'type': 'error', 'message': 'Game launch failed after fix attempt'})}\n\n"
194 | return
195 | except Exception as e:
196 | error_msg = f"Error during automatic fix: {str(e)}"
197 | exception_error_content = f"\n\n---\n\n> ❌ **FIX ATTEMPT FAILED** \n> {error_msg}\n\n---\n\n"
198 | yield f"data: {json.dumps({'type': 'content', 'content': exception_error_content})}\n\n"
199 | yield f"data: {json.dumps({'type': 'error', 'message': 'Game launch failed during fix attempt'})}\n\n"
200 | return
201 | else:
202 | error_msg = f"Game '{game_title}' failed to launch: {error_message}"
203 | no_retry_error_content = (
204 | f"\n\n---\n\n> ❌ **LAUNCH FAILED** \n> {error_msg}\n\n---\n\n"
205 | )
206 | yield f"data: {json.dumps({'type': 'content', 'content': no_retry_error_content})}\n\n"
207 | yield f"data: {json.dumps({'type': 'error', 'message': 'Game launch failed'})}\n\n"
208 | return
209 |
210 | # Max retries exceeded
211 | error_msg = f"Game '{game_title}' failed to launch after {max_retries} automatic fix attempts: {error_message}"
212 | max_retry_error_content = (
213 | f"\n\n---\n\n> ❌ **MAX RETRIES EXCEEDED** \n> {error_msg}\n\n---\n\n"
214 | )
215 | yield f"data: {json.dumps({'type': 'content', 'content': max_retry_error_content})}\n\n"
216 | yield f"data: {json.dumps({'type': 'error', 'message': 'Game launch failed after max retries'})}\n\n"
217 |
--------------------------------------------------------------------------------
/src/infinity_arcade/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Infinity Arcade
7 |
8 |
9 |
10 |
33 |
34 |
35 |
36 |
57 |
58 |
59 |
60 |
61 |
62 |
80 |
81 |
82 |
85 |
Checking system...
86 |
87 |
88 |
155 |
156 |
157 | LET'S GO! >>>
158 | Retry Setup
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
Game Library
167 |
168 |
169 |
Generating game...
170 |
171 |
172 |
173 | No games yet. Create your first game below!
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
LLM Output
182 |
Ready to generate games...
183 |
184 |
185 |
198 |
199 |
200 |
201 |
214 |
215 |
216 |
217 |
218 |
219 |
--------------------------------------------------------------------------------
/src/infinity_arcade/llm_service.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import re
3 | import tempfile
4 | from dataclasses import dataclass
5 | from typing import AsyncGenerator, Optional, Union
6 |
7 | import httpx
8 | from openai import AsyncOpenAI
9 |
10 | from lemonade_client import LemonadeClient
11 |
12 |
13 | logger = logging.getLogger("infinity_arcade.main")
14 |
15 |
16 | @dataclass
17 | class ExtractedCode:
18 | """Represents successfully extracted Python code from an LLM response."""
19 |
20 | code: str
21 | length: int
22 |
23 | def __post_init__(self):
24 | if not self.code or not isinstance(self.code, str):
25 | raise ValueError("Code must be a non-empty string")
26 | if self.length != len(self.code):
27 | raise ValueError("Length must match the actual code length")
28 |
29 | def __str__(self) -> str:
30 | return self.code
31 |
32 |
33 | async def generate_game_title(
34 | lemonade_handle: LemonadeClient, model: str, prompt: str
35 | ) -> str:
36 | logger.debug(f"Generating title for prompt: {prompt[:50]}...")
37 |
38 | try:
39 | # pylint: disable=line-too-long
40 | title_prompt = f"""Generate a short game title (2-3 words maximum) for this game concept: "{prompt}"
41 |
42 | Requirements:
43 | - EXACTLY 2-3 words only
44 | - Should be catchy and describe the game
45 | - No punctuation except spaces
46 | - Examples: "Snake Game", "Space Shooter", "Puzzle Master", "Racing Fun"
47 |
48 | Return ONLY the title, nothing else."""
49 |
50 | messages = [
51 | {
52 | "role": "system",
53 | "content": "You are a game title generator. Return only the title, nothing else.",
54 | },
55 | {"role": "user", "content": title_prompt},
56 | ]
57 |
58 | async with httpx.AsyncClient(timeout=30.0) as client:
59 | response = await client.post(
60 | f"{lemonade_handle.url}/api/v1/chat/completions",
61 | json={
62 | "model": model,
63 | "messages": messages,
64 | "stream": False,
65 | "max_tokens": 20,
66 | "temperature": 0.3,
67 | },
68 | headers={"Content-Type": "application/json"},
69 | )
70 |
71 | if response.status_code == 200:
72 | data = response.json()
73 | if "choices" in data and len(data["choices"]) > 0:
74 | title = data["choices"][0]["message"]["content"].strip()
75 | title = title.strip("\"'").split("\n")[0].strip()
76 | words = title.split()[:3]
77 | final_title = " ".join(words)
78 | logger.debug(f"Generated title: {final_title}")
79 | return final_title
80 | except Exception as e:
81 | logger.warning(f"Failed to generate title: {e}")
82 |
83 | fallback_title = " ".join(prompt.split()[:3]).title()
84 | logger.debug(f"Using fallback title: {fallback_title}")
85 | return fallback_title
86 |
87 |
88 | def _extract_python_code(llm_response: str) -> Optional[ExtractedCode]:
89 | logger.debug(f"Extracting Python code from response of length {len(llm_response)}")
90 |
91 | logger.debug(f"Response start: {repr(llm_response[:500])}")
92 | logger.debug(f"Response end: {repr(llm_response[-500:])}")
93 |
94 | patterns = [
95 | r"```python\s*\n(.*?)\n```",
96 | r"```py\s*\n(.*?)\n```",
97 | r"```\s*\n(.*?)\n```",
98 | ]
99 |
100 | valid_code_blocks = []
101 | for i, pattern in enumerate(patterns):
102 | logger.debug(f"Trying pattern {i+1}: {pattern}")
103 | matches = re.findall(pattern, llm_response, re.DOTALL)
104 | for match in matches:
105 | code = match.strip()
106 | logger.debug(f"Found code block with pattern {i+1}, length: {len(code)}")
107 | logger.debug(f"Extracted code start: {repr(code[:200])}")
108 | if "pygame" in code.lower():
109 | logger.debug("Code contains pygame, validation passed")
110 | valid_code_blocks.append(code)
111 | else:
112 | logger.warning("Code block found but doesn't contain pygame")
113 | logger.debug(f"Code content (first 300 chars): {repr(code[:300])}")
114 |
115 | if valid_code_blocks:
116 | longest_code = max(valid_code_blocks, key=len)
117 | logger.debug(f"Selected longest pygame code block, length: {len(longest_code)}")
118 | return ExtractedCode(code=longest_code, length=len(longest_code))
119 |
120 | logger.error("No valid Python code block found in response")
121 | all_code_blocks = re.findall(r"```.*?\n(.*?)\n```", llm_response, re.DOTALL)
122 | logger.debug(f"Total code blocks found: {len(all_code_blocks)}")
123 | for i, block in enumerate(all_code_blocks):
124 | logger.debug(
125 | f"Block {i+1} length: {len(block)}, starts with: {repr(block[:100])}"
126 | )
127 | return None
128 |
129 |
130 | async def generate_game_code_with_llm(
131 | lemonade_handle: LemonadeClient,
132 | model: str,
133 | mode: str,
134 | content: str,
135 | mode_data: str | None = None,
136 | ) -> Union[str, ExtractedCode, None]:
137 | if mode == "create":
138 | # pylint: disable=line-too-long
139 | system_prompt = """You are an expert Python game developer. Generate a complete, working Python game using pygame based on the user's description.
140 |
141 | Rules:
142 | 1. Use ONLY the pygame library - no external images, sounds, or files
143 | 2. Create everything (graphics, colors, shapes) using pygame's built-in drawing functions
144 | 3. Make the game fully playable and fun
145 | 4. Include proper game mechanics (win/lose conditions, scoring if appropriate)
146 | 5. Use proper pygame event handling and game loop
147 | 6. Add comments explaining key parts of the code
148 | 7. Make sure the game window closes properly when the user clicks the X button
149 | 8. Use reasonable colors and make the game visually appealing with pygame primitives
150 |
151 | Generate ONLY the Python code wrapped in a markdown code block using triple backticks (```python). Do not include any explanations outside the code block."""
152 |
153 | user_prompt = f"Create a game: {content}"
154 | messages = [
155 | {"role": "system", "content": system_prompt},
156 | {"role": "user", "content": user_prompt},
157 | ]
158 | elif mode == "debug":
159 |
160 | system_prompt = "You are a Python expert debugging a pygame script that has an error. Generate ONLY the fixed Python code wrapped in a markdown code block using triple backticks (```python). Do not include any explanations outside the code block."
161 |
162 | user_prompt = f"""Error:
163 | {mode_data}
164 |
165 | Script with error:
166 | ```python
167 | {content}
168 | ```
169 |
170 | Please fix the bug and provide the corrected code.
171 | """
172 | messages = [
173 | {"role": "system", "content": system_prompt},
174 | {"role": "user", "content": user_prompt},
175 | ]
176 | elif mode == "remix":
177 | system_prompt = """You are an expert Python game developer. You will be given an existing pygame game and a modification request. Your task is to modify the existing game according to the user's request while keeping it fully functional.
178 |
179 | Rules:
180 | 1. Use ONLY the pygame library - no external images, sounds, or files
181 | 2. Keep the core game mechanics intact unless specifically asked to change them
182 | 3. Make the requested modifications while ensuring the game remains playable
183 | 4. Maintain proper pygame event handling and game loop
184 | 5. Add comments explaining the changes you made
185 | 6. Make sure the game window closes properly when the user clicks the X button
186 | 7. Use reasonable colors and make the game visually appealing with pygame primitives
187 |
188 | Generate ONLY the complete modified Python code wrapped in a markdown code block using triple backticks (```python)."""
189 |
190 | user_prompt = f"""Here is the existing game code:
191 |
192 | ```python
193 | {content}
194 | ```
195 |
196 | Please modify this game according to this request: {mode_data}
197 |
198 | Provide the complete modified game code."""
199 |
200 | messages = [
201 | {"role": "system", "content": system_prompt},
202 | {"role": "user", "content": user_prompt},
203 | ]
204 | else:
205 | logger.error(f"Invalid mode: {mode}")
206 | yield None
207 | return
208 |
209 | logger.debug(f"=== OpenAI Messages Debug for {mode} mode ===")
210 | logger.debug(f"Number of messages: {len(messages)}")
211 | for i, message in enumerate(messages):
212 | role = message["role"]
213 | content_text = message["content"]
214 | content_length = len(content_text)
215 | logger.debug(
216 | f"Message {i+1} - Role: {role}, Content length: {content_length} chars"
217 | )
218 | if content_length <= 300:
219 | logger.debug(f"Message {i+1} - Full content: {repr(content_text)}")
220 | else:
221 | logger.debug(f"Message {i+1} - Content start: {repr(content_text[:200])}")
222 | logger.debug(f"Message {i+1} - Content end: {repr(content_text[-100:])}")
223 | logger.debug("=== End OpenAI Messages Debug ===")
224 |
225 | try:
226 | openai_client = AsyncOpenAI(
227 | base_url=f"{lemonade_handle.url}/api/v1",
228 | api_key="dummy",
229 | timeout=600.0,
230 | )
231 |
232 | response = await openai_client.chat.completions.create(
233 | model=model,
234 | messages=messages,
235 | stream=True,
236 | max_tokens=4000,
237 | temperature=0.3,
238 | top_p=0.9,
239 | )
240 |
241 | full_response = ""
242 | async for chunk in response:
243 | if chunk.choices and len(chunk.choices) > 0:
244 | delta = chunk.choices[0].delta
245 | if delta.content is not None:
246 | content_chunk = delta.content
247 | full_response += content_chunk
248 | yield content_chunk
249 |
250 | # Save the complete LLM response to a temporary file for debugging
251 | try:
252 | with tempfile.NamedTemporaryFile(
253 | mode="w",
254 | suffix=f"_llm_response_{mode}.txt",
255 | delete=False,
256 | encoding="utf-8",
257 | ) as temp_file:
258 | temp_file.write(full_response)
259 | temp_file_path = temp_file.name
260 | logger.info(
261 | f"DEBUG: Complete LLM response for {mode} mode saved to: {temp_file_path}"
262 | )
263 | logger.debug(f"Full response length: {len(full_response)} characters")
264 | except Exception as e:
265 | logger.error(f"Failed to save LLM response to temp file: {e}")
266 |
267 | extracted_code = _extract_python_code(full_response)
268 | if extracted_code:
269 | logger.debug(f"Successfully extracted code for {mode} mode")
270 | yield extracted_code
271 | else:
272 | logger.error(f"Could not extract code from LLM response in {mode} mode")
273 | yield None
274 | except Exception as e:
275 | logger.error(f"Error calling LLM for {mode}: {e}")
276 | yield None
277 |
278 |
279 | class LLMService:
280 | """Centralized service for all LLM-related operations used by the arcade."""
281 |
282 | def __init__(self, lemonade_handle: LemonadeClient, model: str):
283 | self._lemonade_handle = lemonade_handle
284 | self._model = model
285 |
286 | async def stream_game_code(
287 | self, mode: str, content: str, mode_data: str | None = None
288 | ) -> AsyncGenerator[Union[str, ExtractedCode, None], None]:
289 | async for chunk in generate_game_code_with_llm(
290 | self._lemonade_handle, self._model, mode, content, mode_data
291 | ):
292 | yield chunk
293 |
294 | async def generate_title(self, prompt: str) -> str:
295 | return await generate_game_title(self._lemonade_handle, self._model, prompt)
296 |
--------------------------------------------------------------------------------
/src/infinity_arcade/builtin_games/rainbow_space_invaders.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | Rainbow Space Invaders - Space invaders with rainbow colors
4 | Built-in game for Infinity Arcade
5 | """
6 |
7 | import pygame
8 | import random
9 | import sys
10 | import math
11 |
12 | # Initialize Pygame
13 | pygame.init()
14 |
15 | # Game constants
16 | WINDOW_WIDTH = 800
17 | WINDOW_HEIGHT = 600
18 | FPS = 60
19 |
20 | # Colors
21 | BLACK = (0, 0, 0)
22 | WHITE = (255, 255, 255)
23 |
24 |
25 | def get_rainbow_color(time, offset=0):
26 | """Generate rainbow colors based on time"""
27 | r = int(127 * (1 + math.sin(time * 0.05 + offset)))
28 | g = int(127 * (1 + math.sin(time * 0.05 + offset + 2)))
29 | b = int(127 * (1 + math.sin(time * 0.05 + offset + 4)))
30 | return (r, g, b)
31 |
32 |
33 | class Player:
34 | def __init__(self):
35 | self.x = WINDOW_WIDTH // 2
36 | self.y = WINDOW_HEIGHT - 60
37 | self.width = 40
38 | self.height = 30
39 | self.speed = 5
40 | self.bullets = []
41 | self.shoot_cooldown = 0
42 |
43 | def update(self):
44 | keys = pygame.key.get_pressed()
45 |
46 | if keys[pygame.K_LEFT] and self.x > 0:
47 | self.x -= self.speed
48 | if keys[pygame.K_RIGHT] and self.x < WINDOW_WIDTH - self.width:
49 | self.x += self.speed
50 |
51 | if keys[pygame.K_SPACE] and self.shoot_cooldown <= 0:
52 | self.bullets.append(Bullet(self.x + self.width // 2, self.y))
53 | self.shoot_cooldown = 10
54 |
55 | if self.shoot_cooldown > 0:
56 | self.shoot_cooldown -= 1
57 |
58 | # Update bullets
59 | for bullet in self.bullets[:]:
60 | bullet.update()
61 | if bullet.y < 0:
62 | self.bullets.remove(bullet)
63 |
64 | def draw(self, screen, time):
65 | # Draw player ship with rainbow trail effect
66 | for i in range(5):
67 | color = get_rainbow_color(time, i * 0.5)
68 | offset = i * 2
69 |
70 | # Ship body
71 | ship_rect = pygame.Rect(
72 | self.x + offset,
73 | self.y + offset,
74 | self.width - offset * 2,
75 | self.height - offset * 2,
76 | )
77 | pygame.draw.rect(screen, color, ship_rect)
78 |
79 | # Draw bullets
80 | for bullet in self.bullets:
81 | bullet.draw(screen, time)
82 |
83 |
84 | class Bullet:
85 | def __init__(self, x, y, speed=-8):
86 | self.x = x
87 | self.y = y
88 | self.width = 4
89 | self.height = 10
90 | self.speed = speed
91 |
92 | def update(self):
93 | self.y += self.speed
94 |
95 | def draw(self, screen, time):
96 | color = get_rainbow_color(time * 2, self.x * 0.1)
97 | bullet_rect = pygame.Rect(
98 | self.x - self.width // 2, self.y, self.width, self.height
99 | )
100 | pygame.draw.rect(screen, color, bullet_rect)
101 |
102 | def get_rect(self):
103 | return pygame.Rect(self.x - self.width // 2, self.y, self.width, self.height)
104 |
105 |
106 | class Invader:
107 | def __init__(self, x, y, color_offset):
108 | self.x = x
109 | self.y = y
110 | self.width = 30
111 | self.height = 20
112 | self.color_offset = color_offset
113 | self.alive = True
114 | self.bullets = []
115 | self.shoot_timer = random.randint(60, 180)
116 |
117 | def update(self, direction_x, move_down):
118 | if move_down:
119 | self.y += 20
120 | else:
121 | self.x += direction_x
122 |
123 | # Shooting
124 | self.shoot_timer -= 1
125 | if self.shoot_timer <= 0 and random.random() < 0.01:
126 | self.bullets.append(
127 | Bullet(self.x + self.width // 2, self.y + self.height, 4)
128 | )
129 | self.shoot_timer = random.randint(120, 300)
130 |
131 | # Update bullets
132 | for bullet in self.bullets[:]:
133 | bullet.update()
134 | if bullet.y > WINDOW_HEIGHT:
135 | self.bullets.remove(bullet)
136 |
137 | def draw(self, screen, time):
138 | if self.alive:
139 | color = get_rainbow_color(time, self.color_offset)
140 |
141 | # Draw invader with animated effect
142 | pulse = abs(math.sin(time * 0.1 + self.color_offset))
143 | size_mod = int(pulse * 5)
144 |
145 | invader_rect = pygame.Rect(
146 | self.x - size_mod,
147 | self.y - size_mod,
148 | self.width + size_mod * 2,
149 | self.height + size_mod * 2,
150 | )
151 | pygame.draw.rect(screen, color, invader_rect)
152 |
153 | # Draw eyes
154 | eye_color = WHITE
155 | eye1 = pygame.Rect(self.x + 5, self.y + 5, 6, 6)
156 | eye2 = pygame.Rect(self.x + self.width - 11, self.y + 5, 6, 6)
157 | pygame.draw.rect(screen, eye_color, eye1)
158 | pygame.draw.rect(screen, eye_color, eye2)
159 |
160 | # Draw bullets
161 | for bullet in self.bullets:
162 | bullet.draw(screen, time)
163 |
164 | def get_rect(self):
165 | return pygame.Rect(self.x, self.y, self.width, self.height)
166 |
167 |
168 | class InvaderGroup:
169 | def __init__(self):
170 | self.invaders = []
171 | self.direction = 1
172 | self.move_down = False
173 |
174 | # Create grid of invaders
175 | for row in range(5):
176 | for col in range(10):
177 | x = 100 + col * 60
178 | y = 50 + row * 50
179 | color_offset = row * 2 + col * 0.5
180 | self.invaders.append(Invader(x, y, color_offset))
181 |
182 | def update(self):
183 | # Check if any invader hit the edge
184 | hit_edge = False
185 | for invader in self.invaders:
186 | if invader.alive:
187 | if (invader.x <= 0 and self.direction < 0) or (
188 | invader.x >= WINDOW_WIDTH - invader.width and self.direction > 0
189 | ):
190 | hit_edge = True
191 | break
192 |
193 | if hit_edge:
194 | self.direction *= -1
195 | self.move_down = True
196 | else:
197 | self.move_down = False
198 |
199 | # Update all invaders
200 | for invader in self.invaders:
201 | if invader.alive:
202 | invader.update(self.direction * 2, self.move_down)
203 |
204 | def draw(self, screen, time):
205 | for invader in self.invaders:
206 | invader.draw(screen, time)
207 |
208 | def check_collisions(self, player_bullets):
209 | hits = 0
210 | for bullet in player_bullets[:]:
211 | bullet_rect = bullet.get_rect()
212 | for invader in self.invaders:
213 | if invader.alive and bullet_rect.colliderect(invader.get_rect()):
214 | invader.alive = False
215 | player_bullets.remove(bullet)
216 | hits += 1
217 | break
218 | return hits
219 |
220 | def get_bullets(self):
221 | bullets = []
222 | for invader in self.invaders:
223 | bullets.extend(invader.bullets)
224 | return bullets
225 |
226 | def alive_count(self):
227 | return sum(1 for invader in self.invaders if invader.alive)
228 |
229 | def lowest_y(self):
230 | lowest = 0
231 | for invader in self.invaders:
232 | if invader.alive and invader.y > lowest:
233 | lowest = invader.y
234 | return lowest
235 |
236 |
237 | def create_star_field():
238 | """Create animated star background"""
239 | stars = []
240 | for _ in range(100):
241 | x = random.randint(0, WINDOW_WIDTH)
242 | y = random.randint(0, WINDOW_HEIGHT)
243 | speed = random.uniform(0.5, 2.0)
244 | brightness = random.randint(50, 255)
245 | stars.append([x, y, speed, brightness])
246 | return stars
247 |
248 |
249 | def update_star_field(stars):
250 | """Update star positions"""
251 | for star in stars:
252 | star[1] += star[2] # Move down
253 | if star[1] > WINDOW_HEIGHT:
254 | star[1] = 0
255 | star[0] = random.randint(0, WINDOW_WIDTH)
256 |
257 |
258 | def draw_star_field(screen, stars, time):
259 | """Draw animated stars"""
260 | for star in stars:
261 | pulse = abs(math.sin(time * 0.02 + star[0] * 0.01))
262 | brightness = int(star[3] * pulse)
263 | color = (brightness, brightness, brightness)
264 | pygame.draw.circle(screen, color, (int(star[0]), int(star[1])), 1)
265 |
266 |
267 | def main():
268 | screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
269 | pygame.display.set_caption("Rainbow Space Invaders")
270 | clock = pygame.time.Clock()
271 | font = pygame.font.Font(None, 36)
272 |
273 | player = Player()
274 | invaders = InvaderGroup()
275 | stars = create_star_field()
276 | score = 0
277 | game_over = False
278 | victory = False
279 | time = 0
280 |
281 | while True:
282 | time += 1
283 |
284 | for event in pygame.event.get():
285 | if event.type == pygame.QUIT:
286 | pygame.quit()
287 | sys.exit()
288 |
289 | if event.type == pygame.KEYDOWN:
290 | if (game_over or victory) and event.key == pygame.K_SPACE:
291 | # Restart game
292 | player = Player()
293 | invaders = InvaderGroup()
294 | score = 0
295 | game_over = False
296 | victory = False
297 | time = 0
298 |
299 | if not game_over and not victory:
300 | # Update game objects
301 | player.update()
302 | invaders.update()
303 | update_star_field(stars)
304 |
305 | # Check collisions
306 | hits = invaders.check_collisions(player.bullets)
307 | score += hits * 10
308 |
309 | # Check if player hit by invader bullets
310 | player_rect = pygame.Rect(player.x, player.y, player.width, player.height)
311 | for bullet in invaders.get_bullets():
312 | if bullet.get_rect().colliderect(player_rect):
313 | game_over = True
314 |
315 | # Check win condition
316 | if invaders.alive_count() == 0:
317 | victory = True
318 |
319 | # Check lose condition (invaders reach bottom)
320 | if invaders.lowest_y() > WINDOW_HEIGHT - 100:
321 | game_over = True
322 |
323 | # Draw everything
324 | screen.fill(BLACK)
325 |
326 | # Draw star field
327 | draw_star_field(screen, stars, time)
328 |
329 | if not game_over and not victory:
330 | player.draw(screen, time)
331 | invaders.draw(screen, time)
332 |
333 | # Draw score with rainbow effect
334 | score_color = get_rainbow_color(time * 0.5)
335 | score_text = font.render(f"Score: {score}", True, score_color)
336 | screen.blit(score_text, (10, 10))
337 |
338 | # Draw game state messages
339 | if game_over:
340 | game_over_color = get_rainbow_color(time)
341 | game_over_text = font.render("GAME OVER", True, game_over_color)
342 | restart_text = font.render("Press SPACE to restart", True, WHITE)
343 |
344 | game_over_rect = game_over_text.get_rect(
345 | center=(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2 - 20)
346 | )
347 | restart_rect = restart_text.get_rect(
348 | center=(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2 + 20)
349 | )
350 |
351 | screen.blit(game_over_text, game_over_rect)
352 | screen.blit(restart_text, restart_rect)
353 |
354 | elif victory:
355 | victory_color = get_rainbow_color(time * 2)
356 | victory_text = font.render("VICTORY!", True, victory_color)
357 | restart_text = font.render("Press SPACE to play again", True, WHITE)
358 |
359 | victory_rect = victory_text.get_rect(
360 | center=(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2 - 20)
361 | )
362 | restart_rect = restart_text.get_rect(
363 | center=(WINDOW_WIDTH // 2, WINDOW_HEIGHT // 2 + 20)
364 | )
365 |
366 | screen.blit(victory_text, victory_rect)
367 | screen.blit(restart_text, restart_rect)
368 |
369 | pygame.display.flip()
370 | clock.tick(FPS)
371 |
372 |
373 | if __name__ == "__main__":
374 | main()
375 |
376 | # Copyright (c) 2025 AMD
377 |
--------------------------------------------------------------------------------
/docs/assets/homepage.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | margin: 0 !important;
3 | padding: 0 !important;
4 | border: none !important;
5 | box-shadow: none !important;
6 | background: #000 !important;
7 | }
8 |
9 | /* Homepage specific styles */
10 | .homepage-container {
11 | display: flex;
12 | flex-direction: column;
13 | min-height: 100vh;
14 | background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%);
15 | color: #00ff41;
16 | font-family: 'Courier New', monospace;
17 | }
18 |
19 | .homepage-header {
20 | display: flex;
21 | justify-content: right;
22 | align-items: center;
23 | padding: 20px 40px;
24 | }
25 |
26 | .nav-links {
27 | display: flex;
28 | gap: 30px;
29 | align-items: center;
30 | }
31 |
32 | .nav-link {
33 | text-decoration: none;
34 | transition: all 0.3s ease;
35 | display: flex;
36 | align-items: center;
37 | border-radius: 5px;
38 | }
39 |
40 | .nav-link img {
41 | height: 24px;
42 | transition: all 0.3s ease;
43 | border-radius: 3px;
44 | opacity: 0.8;
45 | }
46 |
47 | .nav-link:hover img {
48 | transform: translateY(-2px);
49 | opacity: 1;
50 | box-shadow: 0 2px 8px rgba(0, 255, 65, 0.2);
51 | }
52 |
53 | .nav-icon {
54 | font-size: 1.2rem;
55 | }
56 |
57 | .hero-section {
58 | flex: 1;
59 | display: flex;
60 | flex-direction: column;
61 | justify-content: center;
62 | align-items: center;
63 | padding: 60px 40px;
64 | text-align: center;
65 | }
66 |
67 | .ascii-banner {
68 | margin-bottom: 20px;
69 | text-align: center;
70 | padding: 10px 20px;
71 | }
72 |
73 | .ascii-banner pre {
74 | font-family: 'Courier New', monospace;
75 | font-size: 0.7rem;
76 | font-weight: bold;
77 | color: #00ff41;
78 | text-shadow: 0 0 10px #00ff41, 0 0 20px #00ff41;
79 | letter-spacing: 1px;
80 | line-height: 1.1;
81 | margin: 10px 0;
82 | padding: 10px;
83 | white-space: pre;
84 | overflow: visible;
85 | display: inline-block;
86 | }
87 |
88 | @keyframes gentle-glow {
89 | 0% { filter: drop-shadow(0 0 20px rgba(0, 255, 65, 0.5)); }
90 | 100% { filter: drop-shadow(0 0 30px rgba(0, 255, 65, 0.7)); }
91 | }
92 |
93 | .hero-subtitle {
94 | font-size: 1.5rem;
95 | color: #a0ffa0;
96 | margin-bottom: 30px;
97 | max-width: 800px;
98 | line-height: 1.6;
99 | text-shadow: 0 0 5px rgba(0, 255, 65, 0.3);
100 | }
101 |
102 | .hero-description {
103 | font-size: 1.1rem;
104 | color: #cccccc;
105 | margin-bottom: 40px;
106 | max-width: 600px;
107 | line-height: 1.7;
108 | }
109 |
110 | .cta-section {
111 | display: flex;
112 | flex-direction: column;
113 | align-items: center;
114 | gap: 40px;
115 | position: relative;
116 | }
117 |
118 | .download-section {
119 | display: flex;
120 | flex-direction: row;
121 | justify-content: center;
122 | align-items: stretch;
123 | gap: 40px;
124 | padding: 30px;
125 | max-width: 700px;
126 | margin: 0 auto;
127 | }
128 |
129 | .download-option {
130 | display: flex;
131 | flex-direction: column;
132 | align-items: center;
133 | justify-content: space-between;
134 | gap: 20px;
135 | flex: 1;
136 | background: rgba(0, 0, 0, 0.4);
137 | border: 1px solid rgba(0, 255, 65, 0.3);
138 | border-radius: 15px;
139 | padding: 25px;
140 | transition: all 0.3s ease;
141 | min-height: 180px;
142 | }
143 |
144 | .download-option:hover {
145 | border-color: rgba(0, 255, 65, 0.6);
146 | box-shadow: 0 5px 20px rgba(0, 255, 65, 0.2);
147 | }
148 |
149 | .platform-header {
150 | text-align: center;
151 | margin-bottom: 10px;
152 | }
153 |
154 | .platform-label {
155 | color: #00ff41;
156 | font-size: 1.3rem;
157 | font-weight: bold;
158 | text-align: center;
159 | letter-spacing: 1px;
160 | margin-bottom: 8px;
161 | text-shadow: 0 0 10px rgba(0, 255, 65, 0.5);
162 | display: block;
163 | }
164 |
165 | .platform-desc {
166 | color: #a0ffa0;
167 | font-size: 0.9rem;
168 | text-align: center;
169 | opacity: 0.8;
170 | display: block;
171 | }
172 |
173 | .download-btn {
174 | background: transparent;
175 | color: #00ff41;
176 | font-size: 1rem;
177 | font-weight: bold;
178 | text-decoration: none;
179 | border-radius: 12px;
180 | border: 2px solid #00ff41;
181 | transition: all 0.3s ease;
182 | display: inline-flex;
183 | flex-direction: row;
184 | align-items: center;
185 | justify-content: center;
186 | gap: 12px;
187 | text-transform: uppercase;
188 | letter-spacing: 1px;
189 | position: relative;
190 | overflow: hidden;
191 | width: 180px;
192 | height: 60px;
193 | padding: 15px 25px;
194 | box-shadow: 0 2px 10px rgba(0, 255, 65, 0.2);
195 | box-sizing: border-box;
196 | }
197 |
198 | .download-btn:hover {
199 | transform: translateY(-2px);
200 | background: rgba(0, 255, 65, 0.1);
201 | border-color: #00cc33;
202 | color: #00cc33;
203 | box-shadow: 0 4px 15px rgba(0, 255, 65, 0.3);
204 | }
205 |
206 | .download-icon {
207 | width: 32px;
208 | height: 32px;
209 | }
210 |
211 | .download-text {
212 | font-size: 1rem;
213 | font-weight: bold;
214 | letter-spacing: 1px;
215 | text-transform: none;
216 | }
217 |
218 | .game-preview {
219 | margin: 30px 0 15px 0;
220 | display: flex;
221 | justify-content: center;
222 | align-items: center;
223 | }
224 |
225 | .game-preview-gif {
226 | max-width: 800px;
227 | width: 100%;
228 | height: auto;
229 | transition: all 0.3s ease;
230 | filter: drop-shadow(0 0 15px rgba(0, 255, 65, 0.3));
231 | }
232 |
233 | .features-grid {
234 | display: grid;
235 | grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
236 | gap: 30px;
237 | margin-top: 25px;
238 | max-width: 900px;
239 | width: 100%;
240 | position: relative;
241 | }
242 |
243 | .feature-card {
244 | background: rgba(0, 0, 0, 0.6);
245 | padding: 25px;
246 | max-width: 320px;
247 | text-align: center;
248 | transition: all 0.3s ease;
249 | position: relative;
250 | overflow: hidden;
251 | border-radius: 15px;
252 | border: 1px solid rgba(0, 255, 65, 0.2);
253 | backdrop-filter: blur(10px);
254 | }
255 |
256 | .feature-card:hover {
257 | border-color: rgba(0, 255, 65, 0.5);
258 | background: rgba(0, 0, 0, 0.8);
259 | }
260 |
261 | .feature-icon {
262 | font-size: 3rem;
263 | margin-bottom: 20px;
264 | display: block;
265 | text-shadow: 0 0 15px currentColor;
266 | }
267 |
268 | .feature-title {
269 | font-size: 1.3rem;
270 | font-weight: bold;
271 | color: #00ff41;
272 | margin-bottom: 15px;
273 | text-transform: uppercase;
274 | letter-spacing: 1px;
275 | }
276 |
277 | .feature-description {
278 | color: #cccccc;
279 | line-height: 1.6;
280 | font-size: 1rem;
281 | }
282 |
283 | .footer-section {
284 | background: rgba(0, 0, 0, 0.9);
285 | border-top: 2px solid #00ff41;
286 | padding: 30px 40px;
287 | text-align: center;
288 | }
289 |
290 | .footer-links {
291 | display: flex;
292 | justify-content: center;
293 | gap: 40px;
294 | margin-bottom: 15px;
295 | }
296 |
297 | .footer-link {
298 | color: #00ff41;
299 | text-decoration: none;
300 | font-size: 1.1rem;
301 | transition: all 0.3s ease;
302 | display: flex;
303 | align-items: center;
304 | gap: 8px;
305 | }
306 |
307 | .footer-link:hover {
308 | color: #00ffff;
309 | text-shadow: 0 0 10px currentColor;
310 | }
311 |
312 | .copyright {
313 | color: #666;
314 | font-size: 0.9rem;
315 | margin-top: 10px;
316 | }
317 |
318 | /* Mobile responsiveness */
319 | @media (max-width: 768px) {
320 | .homepage-header {
321 | flex-direction: column;
322 | gap: 20px;
323 | padding: 20px;
324 | }
325 |
326 | .nav-links {
327 | flex-wrap: wrap;
328 | justify-content: center;
329 | gap: 15px;
330 | }
331 |
332 | .hero-section {
333 | padding: 40px 20px;
334 | }
335 |
336 | .hero-subtitle {
337 | font-size: 1.2rem;
338 | }
339 |
340 | .hero-description {
341 | font-size: 1rem;
342 | }
343 |
344 | .download-btn {
345 | padding: 15px 25px;
346 | min-width: 150px;
347 | }
348 |
349 | .download-section {
350 | flex-direction: column;
351 | gap: 20px;
352 | padding: 20px;
353 | margin: 0 10px;
354 | }
355 |
356 | .download-option {
357 | padding: 20px;
358 | }
359 |
360 | .download-btn {
361 | padding: 15px 20px;
362 | min-width: 140px;
363 | font-size: 0.9rem;
364 | }
365 |
366 | .download-icon {
367 | width: 20px;
368 | height: 20px;
369 | }
370 |
371 | .platform-label {
372 | font-size: 1.1rem;
373 | }
374 |
375 | .platform-desc {
376 | font-size: 0.8rem;
377 | }
378 |
379 | .game-preview-gif {
380 | max-width: 300px;
381 | border-width: 2px;
382 | }
383 |
384 | .game-preview {
385 | margin: 20px 0 10px 0;
386 | }
387 |
388 | .features-grid {
389 | grid-template-columns: 1fr;
390 | gap: 20px;
391 | }
392 |
393 | .footer-links {
394 | flex-direction: column;
395 | gap: 20px;
396 | }
397 |
398 | .ascii-banner pre {
399 | font-size: 0.5rem;
400 | overflow-x: auto;
401 | white-space: pre;
402 | }
403 |
404 | .section-divider {
405 | height: 40px;
406 | margin: 15px 0 25px 0;
407 | }
408 |
409 | .divider-line {
410 | width: 90%;
411 | height: 1.5px;
412 | }
413 |
414 | .divider-glow {
415 | width: 80%;
416 | height: 15px;
417 | }
418 |
419 | .footer-divider {
420 | margin: 10px 0 10px 0;
421 | }
422 |
423 | .footer-divider .divider-line {
424 | width: 70%;
425 | }
426 |
427 | .footer-divider .divider-glow {
428 | width: 60%;
429 | }
430 | }
431 |
432 | /* Modern section dividers with retro glow effect */
433 | .section-divider {
434 | position: relative;
435 | width: 100%;
436 | height: 60px;
437 | display: flex;
438 | align-items: center;
439 | justify-content: center;
440 | margin: 25px 0 40px 0;
441 | overflow: hidden;
442 | }
443 |
444 | .divider-line {
445 | position: relative;
446 | width: 80%;
447 | height: 2px;
448 | background: linear-gradient(90deg,
449 | transparent 0%,
450 | rgba(0, 255, 65, 0.2) 10%,
451 | rgba(0, 255, 65, 0.8) 50%,
452 | rgba(0, 255, 65, 0.2) 90%,
453 | transparent 100%
454 | );
455 | border-radius: 1px;
456 | z-index: 2;
457 | }
458 |
459 | .divider-line::before {
460 | content: '';
461 | position: absolute;
462 | top: 50%;
463 | left: 50%;
464 | transform: translate(-50%, -50%);
465 | width: 60%;
466 | height: 1px;
467 | background: linear-gradient(90deg,
468 | transparent 0%,
469 | #00ffff 20%,
470 | #00ff41 50%,
471 | #00ffff 80%,
472 | transparent 100%
473 | );
474 | animation: pulse-divider 3s ease-in-out infinite;
475 | }
476 |
477 | .divider-glow {
478 | position: absolute;
479 | top: 50%;
480 | left: 50%;
481 | transform: translate(-50%, -50%);
482 | width: 70%;
483 | height: 20px;
484 | background: radial-gradient(ellipse,
485 | rgba(0, 255, 65, 0.15) 0%,
486 | rgba(0, 255, 65, 0.05) 50%,
487 | transparent 70%
488 | );
489 | z-index: 1;
490 | animation: glow-pulse 4s ease-in-out infinite alternate;
491 | }
492 |
493 | @keyframes pulse-divider {
494 | 0%, 100% {
495 | opacity: 0.8;
496 | transform: translate(-50%, -50%) scaleX(1);
497 | }
498 | 50% {
499 | opacity: 1;
500 | transform: translate(-50%, -50%) scaleX(1.1);
501 | }
502 | }
503 |
504 | @keyframes glow-pulse {
505 | 0% {
506 | opacity: 0.6;
507 | transform: translate(-50%, -50%) scale(1);
508 | }
509 | 100% {
510 | opacity: 1;
511 | transform: translate(-50%, -50%) scale(1.2);
512 | }
513 | }
514 |
515 | .footer-divider {
516 | margin: 15px 0 15px 0;
517 | }
518 |
519 | .footer-divider .divider-line {
520 | width: 60%;
521 | background: linear-gradient(90deg,
522 | transparent 0%,
523 | rgba(0, 255, 65, 0.1) 20%,
524 | rgba(0, 255, 65, 0.4) 50%,
525 | rgba(0, 255, 65, 0.1) 80%,
526 | transparent 100%
527 | );
528 | }
529 |
530 | .footer-divider .divider-glow {
531 | width: 50%;
532 | background: radial-gradient(ellipse,
533 | rgba(0, 255, 65, 0.08) 0%,
534 | rgba(0, 255, 65, 0.03) 50%,
535 | transparent 70%
536 | );
537 | }
538 |
539 | /* Cursor blink animation for typewriter effect */
540 | @keyframes blink {
541 | 0%, 50% { border-right-color: #00ff41; }
542 | 51%, 100% { border-right-color: transparent; }
543 | }
544 |
--------------------------------------------------------------------------------
/test/lemonade_client_integration.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import patch
3 | import asyncio
4 | import sys
5 | import os
6 | import time
7 | import subprocess
8 | import platform
9 | import tempfile
10 | import signal
11 | from pathlib import Path
12 |
13 | # Add the src directory to the path for module imports
14 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
15 | from lemonade_client import LemonadeClient
16 | from infinity_arcade.main import LEMONADE_MINIMUM_VERSION
17 |
18 |
19 | class TestLemonadeClientIntegration(unittest.TestCase):
20 | """Integration tests for LemonadeClient that actually interact with a real Lemonade server.
21 |
22 | These tests will:
23 | 1. Install lemonade-sdk if not available
24 | 2. Install lemonade-server if needed
25 | 3. Start the server and test real API interactions
26 | 4. Test model installation and loading
27 | 5. Clean up server processes when done
28 | """
29 |
30 | @classmethod
31 | def setUpClass(cls):
32 | """Set up test fixtures for the entire test class."""
33 | cls.client = LemonadeClient(minimum_version=LEMONADE_MINIMUM_VERSION)
34 | cls.server_started = False
35 | cls.test_model = "Qwen3-0.6B-GGUF" # Small model for testing
36 | cls.setup_timeout = 300 # 5 minutes for setup
37 | cls.test_timeout = 60 # 1 minute for individual tests
38 |
39 | @classmethod
40 | def tearDownClass(cls):
41 | """Clean up after all tests."""
42 | if cls.server_started and cls.client.server_process:
43 | try:
44 | cls.client.server_process.terminate()
45 | cls.client.server_process.wait(timeout=10)
46 | except Exception as e:
47 | print(f"Warning: Failed to cleanly stop server: {e}")
48 | if (
49 | cls.client.server_process
50 | and cls.client.server_process.poll() is None
51 | ):
52 | try:
53 | cls.client.server_process.kill()
54 | except Exception:
55 | pass
56 |
57 | async def async_setUp(self):
58 | """Async setup for each test."""
59 | # Reset client state
60 | self.client.reset_server_state()
61 |
62 | def setUp(self):
63 | """Set up for each test."""
64 | self.loop = asyncio.new_event_loop()
65 | asyncio.set_event_loop(self.loop)
66 | self.loop.run_until_complete(self.async_setUp())
67 |
68 | def tearDown(self):
69 | """Clean up after each test."""
70 | if self.loop:
71 | # Clean up any pending tasks
72 | pending = asyncio.all_tasks(self.loop)
73 | for task in pending:
74 | task.cancel()
75 | if pending:
76 | self.loop.run_until_complete(
77 | asyncio.gather(*pending, return_exceptions=True)
78 | )
79 | self.loop.close()
80 |
81 | def run_async(self, coro, timeout=None):
82 | """Helper to run async functions in tests."""
83 | if timeout is None:
84 | timeout = self.test_timeout
85 | return asyncio.wait_for(coro, timeout=timeout)
86 |
87 | def test_02_check_lemonade_server_version(self):
88 | """Test checking lemonade server version."""
89 | result = self.loop.run_until_complete(
90 | self.run_async(self.client.check_lemonade_server_version())
91 | )
92 |
93 | self.assertIsInstance(result, dict)
94 | self.assertIn("installed", result)
95 | self.assertIn("version", result)
96 | self.assertIn("compatible", result)
97 | self.assertIn("required_version", result)
98 |
99 | self.assertTrue(
100 | result["compatible"],
101 | f"Server version {result['version']} should be compatible with minimum {result['required_version']}",
102 | )
103 |
104 | def test_03_start_lemonade_server(self):
105 | """Test starting lemonade server."""
106 | # Check if server is already running
107 | running = self.loop.run_until_complete(
108 | self.run_async(self.client.check_lemonade_server_running())
109 | )
110 |
111 | if not running:
112 | print("Starting lemonade server...")
113 | result = self.loop.run_until_complete(
114 | self.run_async(
115 | self.client.start_lemonade_server(), timeout=self.setup_timeout
116 | )
117 | )
118 |
119 | self.assertTrue(
120 | result["success"],
121 | f"Server start should succeed: {result.get('message', '')}",
122 | )
123 |
124 | if result["success"]:
125 | self.__class__.server_started = True
126 |
127 | # Wait for server to be fully up
128 | print("Waiting for server to be ready...")
129 | max_wait = 120 # 2 minutes
130 | wait_start = time.time()
131 |
132 | while time.time() - wait_start < max_wait:
133 | try:
134 | api_online = self.loop.run_until_complete(
135 | self.run_async(
136 | self.client.check_lemonade_server_api(), timeout=10
137 | )
138 | )
139 | if api_online:
140 | print("Server API is ready!")
141 | break
142 | except Exception as e:
143 | print(f"Waiting for server... ({e})")
144 |
145 | time.sleep(5)
146 | else:
147 | self.fail("Server did not become ready within timeout")
148 |
149 | def test_04_check_lemonade_server_api(self):
150 | """Test checking if lemonade server API is responding."""
151 | result = self.loop.run_until_complete(
152 | self.run_async(self.client.check_lemonade_server_api())
153 | )
154 | self.assertTrue(result, "Server API should be responding")
155 |
156 | def test_05_get_available_models(self):
157 | """Test getting available models from server."""
158 | models = self.loop.run_until_complete(
159 | self.run_async(self.client.get_available_models())
160 | )
161 | self.assertIsInstance(models, list, "Should return a list of models")
162 | # Note: List might be empty if no models are installed yet
163 |
164 | def test_06_install_model(self):
165 | """Test installing a model."""
166 | # First check if model is already installed
167 | check_result = self.loop.run_until_complete(
168 | self.run_async(self.client.check_model_installed(self.test_model))
169 | )
170 |
171 | if not check_result["installed"]:
172 | print(f"Installing test model: {self.test_model}")
173 | result = self.loop.run_until_complete(
174 | self.run_async(
175 | self.client.install_model(self.test_model),
176 | timeout=self.setup_timeout,
177 | )
178 | )
179 |
180 | self.assertTrue(
181 | result["success"],
182 | f"Model installation should succeed: {result.get('message', '')}",
183 | )
184 |
185 | # Verify model is now installed
186 | check_result_after = self.loop.run_until_complete(
187 | self.run_async(self.client.check_model_installed(self.test_model))
188 | )
189 | self.assertTrue(
190 | check_result_after["installed"],
191 | "Model should be installed after installation",
192 | )
193 | else:
194 | print(f"Test model {self.test_model} already installed")
195 |
196 | def test_07_check_model_installed(self):
197 | """Test checking if a model is installed."""
198 | result = self.loop.run_until_complete(
199 | self.run_async(self.client.check_model_installed(self.test_model))
200 | )
201 |
202 | self.assertIsInstance(result, dict)
203 | self.assertIn("installed", result)
204 | self.assertIn("model_name", result)
205 | self.assertEqual(result["model_name"], self.test_model)
206 |
207 | def test_08_load_model(self):
208 | """Test loading a model."""
209 | # Ensure model is installed first
210 | check_result = self.loop.run_until_complete(
211 | self.run_async(self.client.check_model_installed(self.test_model))
212 | )
213 |
214 | if check_result["installed"]:
215 | print(f"Loading test model: {self.test_model}")
216 | result = self.loop.run_until_complete(
217 | self.run_async(
218 | self.client.load_model(self.test_model), timeout=self.setup_timeout
219 | )
220 | )
221 |
222 | self.assertTrue(
223 | result["success"],
224 | f"Model loading should succeed: {result.get('message', '')}",
225 | )
226 | else:
227 | self.skipTest(f"Test model {self.test_model} not installed")
228 |
229 | def test_09_check_model_loaded(self):
230 | """Test checking if a model is loaded."""
231 | result = self.loop.run_until_complete(
232 | self.run_async(self.client.check_model_loaded(self.test_model))
233 | )
234 |
235 | self.assertIsInstance(result, dict)
236 | self.assertIn("loaded", result)
237 | self.assertIn("model_name", result)
238 | self.assertIn("current_model", result)
239 | self.assertEqual(result["model_name"], self.test_model)
240 |
241 | def test_10_get_system_info(self):
242 | """Test getting system info from real server."""
243 | result = self.loop.run_until_complete(
244 | self.run_async(self.client.get_system_info())
245 | )
246 |
247 | if result is not None:
248 | self.assertIsInstance(result, dict)
249 | self.assertIn("OS Version", result)
250 | self.assertIn("Physical Memory", result)
251 | self.assertIn("devices", result)
252 |
253 | # Verify devices structure
254 | devices = result["devices"]
255 | self.assertIsInstance(devices, dict)
256 |
257 | # Should have at least CPU info
258 | if "cpu" in devices:
259 | self.assertIn("available", devices["cpu"])
260 | else:
261 | self.skipTest("System info API not available")
262 |
263 | def test_11_select_model_for_hardware(self):
264 | """Test hardware-based model selection with real system info."""
265 | result = self.loop.run_until_complete(
266 | self.run_async(self.client.select_model_for_hardware())
267 | )
268 |
269 | self.assertIsInstance(result, tuple)
270 | self.assertEqual(len(result), 2)
271 |
272 | model_name, size_gb = result
273 | self.assertIsInstance(model_name, str)
274 | self.assertIsInstance(size_gb, (int, float))
275 | self.assertGreater(size_gb, 0)
276 |
277 | # Should be one of the known models from the MODELS dictionary
278 | from lemonade_client.lemonade_client import MODELS
279 |
280 | known_models = [model_info[0] for model_info in MODELS.values()]
281 | self.assertIn(model_name, known_models)
282 |
283 | def test_12_system_info_caching(self):
284 | """Test that system info caching works correctly."""
285 | import tempfile
286 | import os
287 |
288 | with tempfile.TemporaryDirectory() as temp_dir:
289 | # First call should fetch from API and cache
290 | result1 = self.loop.run_until_complete(
291 | self.run_async(self.client.get_system_info(cache_dir=temp_dir))
292 | )
293 |
294 | if result1 is not None:
295 | # Check that cache file was created
296 | cache_file = os.path.join(temp_dir, "lemonade_system_info_cache.json")
297 | self.assertTrue(os.path.exists(cache_file))
298 |
299 | # Second call should use cache - verify by checking API wasn't called again
300 | with patch("httpx.AsyncClient") as mock_client:
301 | result2 = self.loop.run_until_complete(
302 | self.run_async(self.client.get_system_info(cache_dir=temp_dir))
303 | )
304 |
305 | # API should not have been called since we're using cache
306 | mock_client.assert_not_called()
307 |
308 | # Results should be identical
309 | self.assertEqual(result1, result2)
310 | else:
311 | self.skipTest("System info API not available")
312 |
313 |
314 | def run_async_test(coro):
315 | """Helper function to run async tests."""
316 | try:
317 | loop = asyncio.get_event_loop()
318 | except RuntimeError:
319 | loop = asyncio.new_event_loop()
320 | asyncio.set_event_loop(loop)
321 |
322 | try:
323 | return loop.run_until_complete(coro)
324 | finally:
325 | # Clean up any pending tasks
326 | pending = asyncio.all_tasks(loop)
327 | for task in pending:
328 | task.cancel()
329 | if pending:
330 | loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
331 |
332 |
333 | # Convert async test methods to sync for unittest
334 | async_test_methods = []
335 | for attr_name in dir(TestLemonadeClientIntegration):
336 | attr = getattr(TestLemonadeClientIntegration, attr_name)
337 | if (
338 | callable(attr)
339 | and attr_name.startswith("test_")
340 | and asyncio.iscoroutinefunction(attr)
341 | ):
342 | async_test_methods.append((attr_name, attr))
343 |
344 | # Apply the conversion with proper closure handling
345 | for attr_name, original_method in async_test_methods:
346 |
347 | def make_sync_test(method):
348 | def sync_test(self):
349 | return run_async_test(method(self))
350 |
351 | return sync_test
352 |
353 | setattr(TestLemonadeClientIntegration, attr_name, make_sync_test(original_method))
354 |
355 |
356 | if __name__ == "__main__":
357 | # Set up logging to see what's happening
358 | import logging
359 |
360 | logging.basicConfig(
361 | level=logging.DEBUG
362 | ) # Enable DEBUG level to see command details
363 |
364 | # Suppress noisy httpcore debug messages
365 | logging.getLogger("httpcore").setLevel(logging.WARNING)
366 | logging.getLogger("httpx").setLevel(logging.WARNING)
367 |
368 | # Run with verbose output
369 | unittest.main(verbosity=2)
370 |
--------------------------------------------------------------------------------
/docs/lemonade_client_api.md:
--------------------------------------------------------------------------------
1 | # LemonadeClient API Reference
2 |
3 | The `LemonadeClient` class provides a comprehensive interface for integrating with lemonade-server in your applications. It handles installation, configuration, server management, and model operations with cross-platform compatibility.
4 |
5 | ## Suggested Workflow
6 |
7 | Here's the typical sequence of API calls for setting up a lemonade-server-based application:
8 |
9 | ### 1. Initial Setup and Environment Check
10 | ```python
11 | from lemonade_client import LemonadeClient
12 |
13 | # Create client with minimum version requirement
14 | client = LemonadeClient(minimum_version="9.0.3")
15 |
16 | # Check deployment environment
17 | is_pyinstaller = client.is_pyinstaller_environment()
18 | ```
19 |
20 | ### 2. Installation Status Check
21 | ```python
22 | # Check if lemonade-server is installed and compatible
23 | version_info = await client.check_lemonade_server_version()
24 | if not version_info["installed"] or not version_info["compatible"]:
25 | # Need to install or upgrade
26 | result = await client.download_and_install_lemonade_server()
27 | if result["success"]:
28 | # Refresh environment after installation
29 | client.refresh_environment()
30 | client.reset_server_state()
31 | ```
32 |
33 | ### 3. Server Management
34 | ```python
35 | # Check if server is running
36 | is_running = await client.check_lemonade_server_running()
37 | if not is_running:
38 | # Start the server
39 | start_result = await client.start_lemonade_server()
40 |
41 | # Verify API connectivity
42 | api_online = await client.check_lemonade_server_api()
43 | ```
44 |
45 | ### 4. Model Management
46 |
47 | **Option A: Automatic Hardware-Based Selection**
48 | ```python
49 | # Select optimal model based on hardware (optional)
50 | model_name, size_gb = await client.select_model_for_hardware()
51 | print(f"Selected model: {model_name} ({size_gb} GB)")
52 | ```
53 |
54 | **Option B: Manual Model Selection**
55 | ```python
56 | # Or specify your own model directly
57 | model_name = "Qwen3-4B-Instruct-2507-GGUF" # Your choice
58 | print(f"Using custom model: {model_name}")
59 | ```
60 |
61 | **Common Steps (regardless of selection method):**
62 | ```python
63 | # Check if model is installed
64 | model_status = await client.check_model_installed(model_name)
65 | if not model_status["installed"]:
66 | # Install the model
67 | install_result = await client.install_model(model_name)
68 |
69 | # Check if model is loaded
70 | load_status = await client.check_model_loaded(model_name)
71 | if not load_status["loaded"]:
72 | # Load the model
73 | load_result = await client.load_model(model_name)
74 | ```
75 |
76 | ### 5. Ready for Inference
77 | Once the above steps complete successfully, your application can make inference requests to `client.url` (default: `http://localhost:8000`) using the OpenAI-compatible API.
78 |
79 | ---
80 |
81 | ## Constructor
82 |
83 | #### `LemonadeClient(minimum_version: str = "9.0.3", logger=None)`
84 | Initialize a new LemonadeClient instance.
85 |
86 | **Parameters:**
87 | - `minimum_version` (str, optional): Minimum required version of lemonade-server. Defaults to "9.0.3". The client will check server compatibility against this version.
88 | - `logger` (logging.Logger, optional): Logger instance to use for logging. If None, creates a default logger named "lemonade_client".
89 |
90 | **When to use:** Create a client instance at the start of your application. Specify the minimum version your application requires to ensure compatibility.
91 |
92 | **Example:**
93 | ```python
94 | import logging
95 |
96 | # Use default minimum version (9.0.3) and default logger
97 | client = LemonadeClient()
98 |
99 | # Specify custom minimum version
100 | client = LemonadeClient(minimum_version="9.0.5")
101 |
102 | # Use custom logger
103 | custom_logger = logging.getLogger("my_app")
104 | client = LemonadeClient(minimum_version="9.0.5", logger=custom_logger)
105 |
106 | # Version checking will use your specified minimum
107 | version_info = await client.check_lemonade_server_version()
108 | print(f"Required: {version_info['required_version']}") # Shows your minimum_version
109 | print(f"Compatible: {version_info['compatible']}") # True if server >= minimum_version
110 | ```
111 |
112 | ---
113 |
114 | ## API Reference
115 |
116 | ### Environment and System Detection
117 |
118 | #### `is_pyinstaller_environment()`
119 | Check if the application is running in a PyInstaller bundle environment.
120 |
121 | **When to use:** Determine installation method preferences or adjust behavior based on deployment type.
122 |
123 | **Returns:** `bool` - True if running in PyInstaller bundle, False otherwise
124 |
125 | **Example:**
126 | ```python
127 | if client.is_pyinstaller_environment():
128 | print("Running as packaged executable")
129 | else:
130 | print("Running in development environment")
131 | ```
132 |
133 | ---
134 |
135 | #### `find_lemonade_server_paths()`
136 | Find lemonade-server installation paths by scanning the system PATH.
137 |
138 | **When to use:** Discover where lemonade-server binaries are installed on the system. Helpful for apps that need to verify installation locations or debug path issues.
139 |
140 | **Returns:** `List[str]` - List of directory paths containing lemonade-server installations
141 |
142 | **Example:**
143 | ```python
144 | paths = client.find_lemonade_server_paths()
145 | print(f"Found lemonade-server in: {paths}")
146 | ```
147 |
148 | ---
149 |
150 | #### `refresh_environment()`
151 | Refresh environment variables from the system registry (Windows only).
152 |
153 | **When to use:** After installing lemonade-server to pick up newly added PATH entries without requiring an application restart. Essential for apps that install lemonade-server programmatically and need immediate access to the commands.
154 |
155 | **Example:**
156 | ```python
157 | # After installation
158 | await client.download_and_install_lemonade_server()
159 | client.refresh_environment() # Pick up new PATH entries
160 | client.reset_server_state() # Clear cached commands
161 | ```
162 |
163 | ---
164 |
165 | #### `reset_server_state()`
166 | Reset cached server state after installation changes or configuration updates.
167 |
168 | **When to use:** Call this when you've installed/updated lemonade-server or changed system configuration to ensure the client rediscovers server commands and processes. Essential after installation operations to avoid using stale cached paths.
169 |
170 | **Example:**
171 | ```python
172 | # After any installation or configuration change
173 | client.reset_server_state()
174 | ```
175 |
176 | ---
177 |
178 | ### Core Server Operations
179 |
180 | #### `execute_lemonade_server_command(args, timeout=10, use_popen=False, stdout_file=None, stderr_file=None)`
181 | Execute lemonade-server commands using the best available method for the system.
182 |
183 | **When to use:** As the primary interface for running any lemonade-server command. The method automatically discovers the lemonade-server installation and caches the successful command for future use. Essential for cross-platform compatibility.
184 |
185 | **Parameters:**
186 | - `args: List[str]` - Command arguments to pass to lemonade-server (e.g., `["--version"]`, `["serve"]`)
187 | - `timeout: int` - Maximum seconds to wait for command completion (ignored for background processes)
188 | - `use_popen: bool` - True for background processes that shouldn't block, False for commands with output
189 | - `stdout_file` - File handle to redirect standard output (only with use_popen=True)
190 | - `stderr_file` - File handle to redirect error output (only with use_popen=True)
191 |
192 | **Returns:** `subprocess.CompletedProcess` for regular commands, `subprocess.Popen` for background processes, or `None` if all command attempts failed
193 |
194 | **Example:**
195 | ```python
196 | # Check version
197 | result = await client.execute_lemonade_server_command(["--version"])
198 | if result:
199 | print(f"Version: {result.stdout}")
200 |
201 | # Start server in background
202 | process = await client.execute_lemonade_server_command(
203 | ["serve"],
204 | use_popen=True
205 | )
206 | ```
207 |
208 | ---
209 |
210 | ### Installation and Setup
211 |
212 | #### `check_lemonade_server_version()`
213 | Check lemonade-server installation status and version compatibility.
214 |
215 | **When to use:** Verify that lemonade-server is installed and meets minimum version requirements before attempting to use server features. Essential for displaying installation status and guiding users through setup.
216 |
217 | **Returns:** `dict` with keys:
218 | - `installed: bool` - Whether lemonade-server is found
219 | - `version: str` - Version string or None
220 | - `compatible: bool` - Whether version meets minimum requirements
221 | - `required_version: str` - Minimum required version
222 |
223 | **Example:**
224 | ```python
225 | version_info = await client.check_lemonade_server_version()
226 | if version_info["installed"] and version_info["compatible"]:
227 | print(f"lemonade-server {version_info['version']} is ready")
228 | else:
229 | print(f"Need version {version_info['required_version']} or higher")
230 | ```
231 |
232 | ---
233 |
234 | #### `download_and_install_lemonade_server()`
235 | Download and install lemonade-server using the platform-specific installer.
236 |
237 | **When to use:** As the primary installation method. On Windows, downloads and launches the MSI installer. On Linux, directs users to the installation options page for manual .deb package installation.
238 |
239 | **Platform Behavior:**
240 | - **Windows**: Downloads `lemonade-server-minimal.msi` and launches it with `msiexec`
241 | - **Linux**: Returns instructions to visit `https://lemonade-server.ai/install_options.html` for .deb package download
242 |
243 | **Returns:** `dict` with keys:
244 | - `success: bool` - Whether installation succeeded or launched successfully
245 | - `message: str` - Status message or error details
246 | - `interactive: bool` (optional) - Whether installer requires user interaction (Windows only)
247 | - `install_link: str` (optional) - Link for manual installation if automated fails
248 |
249 | **Example:**
250 | ```python
251 | result = await client.download_and_install_lemonade_server()
252 | if result["success"]:
253 | print("Installation completed")
254 | if result.get("interactive"):
255 | print("Please complete the installer UI")
256 | client.refresh_environment()
257 | client.reset_server_state()
258 | else:
259 | print(f"Installation failed: {result['message']}")
260 | if "install_link" in result:
261 | print(f"Manual installation: {result['install_link']}")
262 | ```
263 |
264 | ---
265 |
266 | ### Server Status and Management
267 |
268 | #### `check_lemonade_server_running()`
269 | Check if the lemonade-server process is currently running.
270 |
271 | **When to use:** Determine server status before attempting operations that require a running server. Helps decide whether to start the server or proceed with API calls.
272 |
273 | **Returns:** `bool` - True if server process is running, False otherwise
274 |
275 | **Example:**
276 | ```python
277 | if await client.check_lemonade_server_running():
278 | print("Server is running")
279 | else:
280 | print("Need to start server")
281 | await client.start_lemonade_server()
282 | ```
283 |
284 | ---
285 |
286 | #### `start_lemonade_server()`
287 | Start the lemonade-server process in the background.
288 |
289 | **When to use:** Launch the server when it's not running and your app needs server functionality. The server runs in a separate process and the method tracks the process to avoid multiple instances.
290 |
291 | **Returns:** `dict` with keys:
292 | - `success: bool` - Whether server started successfully
293 | - `message: str` - Status message or error details
294 |
295 | **Example:**
296 | ```python
297 | result = await client.start_lemonade_server()
298 | if result["success"]:
299 | print("Server started successfully")
300 | # Wait a moment for startup
301 | await asyncio.sleep(2)
302 | else:
303 | print(f"Failed to start server: {result['message']}")
304 | ```
305 |
306 | ---
307 |
308 | #### `check_lemonade_server_api()`
309 | Check if the lemonade-server API is responding to requests.
310 |
311 | **When to use:** Verify that the server is not only running but also accepting API connections. More reliable than process checks for determining if the server is ready to handle requests.
312 |
313 | **Returns:** `bool` - True if server API is responding, False otherwise
314 |
315 | **Example:**
316 | ```python
317 | if await client.check_lemonade_server_api():
318 | print("API is ready for requests")
319 | # Can now make inference calls
320 | else:
321 | print("API not responding, check server status")
322 | ```
323 |
324 | ---
325 |
326 | ### Model Management
327 |
328 | #### `get_available_models()`
329 | Retrieve the list of models available on the lemonade-server.
330 |
331 | **When to use:** Discover which models are installed and available for use. Helpful for displaying model options to users or verifying that required models are available before attempting to use them.
332 |
333 | **Returns:** `List[str]` - List of model names/IDs available on the server, empty list if none found
334 |
335 | **Example:**
336 | ```python
337 | models = await client.get_available_models()
338 | print(f"Available models: {models}")
339 | for model in models:
340 | print(f" - {model}")
341 | ```
342 |
343 | ---
344 |
345 | #### `check_model_installed(model)`
346 | Check if a specific model is installed on the server.
347 |
348 | **When to use:** Verify model availability before attempting to load or use a model. Essential for apps that depend on specific models to function properly.
349 |
350 | **Parameters:**
351 | - `model: str` - The model name/ID to check for (e.g., "Qwen3-0.6B-GGUF")
352 |
353 | **Returns:** `dict` with keys:
354 | - `installed: bool` - Whether the model is available
355 | - `model_name: str` - The requested model name
356 |
357 | **Example:**
358 | ```python
359 | required_model = "Qwen3-0.6B-GGUF"
360 | status = await client.check_model_installed(required_model)
361 | if status["installed"]:
362 | print(f"Model {required_model} is available")
363 | else:
364 | print(f"Need to install {required_model}")
365 | await client.install_model(required_model)
366 | ```
367 |
368 | ---
369 |
370 | #### `check_model_loaded(model)`
371 | Check if a specific model is currently loaded and ready for inference.
372 |
373 | **When to use:** Verify that a model is loaded before making inference requests. Models must be loaded before they can be used for chat completions or other inference operations.
374 |
375 | **Parameters:**
376 | - `model: str` - The model name/ID to check (e.g., "Qwen3-0.6B-GGUF")
377 |
378 | **Returns:** `dict` with keys:
379 | - `loaded: bool` - Whether the model is currently loaded
380 | - `model_name: str` - The requested model name
381 | - `current_model: str` - Name of currently loaded model (may be different)
382 |
383 | **Example:**
384 | ```python
385 | required_model = "Qwen3-0.6B-GGUF"
386 | status = await client.check_model_loaded(required_model)
387 | if status["loaded"]:
388 | print(f"Model {required_model} is ready for inference")
389 | else:
390 | current = status["current_model"]
391 | print(f"Current model: {current}, need to load {required_model}")
392 | await client.load_model(required_model)
393 | ```
394 |
395 | ---
396 |
397 | #### `install_model(model)`
398 | Download and install a model on the lemonade-server.
399 |
400 | **When to use:** Install models that your app requires but aren't currently available on the server. The installation process may take several minutes for large models and requires an active internet connection.
401 |
402 | **Parameters:**
403 | - `model: str` - The model name/ID to install (e.g., "Qwen3-0.6B-GGUF")
404 |
405 | **Returns:** `dict` with keys:
406 | - `success: bool` - Whether installation succeeded
407 | - `message: str` - Success message or error details
408 |
409 | **Example:**
410 | ```python
411 | model_name = "Qwen3-0.6B-GGUF"
412 | print(f"Installing {model_name}...")
413 | result = await client.install_model(model_name)
414 | if result["success"]:
415 | print("Model installed successfully")
416 | else:
417 | print(f"Installation failed: {result['message']}")
418 | ```
419 |
420 | ---
421 |
422 | #### `load_model(model)`
423 | Load a model into memory for inference operations.
424 |
425 | **When to use:** Prepare an installed model for use. Models must be loaded before they can handle chat completions or other inference requests. Only one model can be loaded at a time.
426 |
427 | **Parameters:**
428 | - `model: str` - The model name/ID to load (e.g., "Qwen3-0.6B-GGUF")
429 |
430 | **Returns:** `dict` with keys:
431 | - `success: bool` - Whether model loaded successfully
432 | - `message: str` - Success message or error details
433 |
434 | **Example:**
435 | ```python
436 | model_name = "Qwen3-0.6B-GGUF"
437 | print(f"Loading {model_name}...")
438 | result = await client.load_model(model_name)
439 | if result["success"]:
440 | print("Model loaded and ready for inference")
441 | # Can now make API calls to client.url
442 | else:
443 | print(f"Loading failed: {result['message']}")
444 | ```
445 |
446 | ---
447 |
448 | ### Hardware-Based Model Selection
449 |
450 | #### `get_system_info(cache_dir=None, cache_duration_hours=None)`
451 | Get system information from lemonade-server with caching support.
452 |
453 | **When to use:** Retrieve detailed hardware information including CPU, GPU, NPU, and memory specs. The system-info endpoint is slow, so results are cached by default. Cache never expires unless cache_duration_hours is explicitly set.
454 |
455 | **Parameters:**
456 | - `cache_dir: Optional[str]` - Directory to store cache file (defaults to ~/.cache/lemonade)
457 | - `cache_duration_hours: Optional[int]` - Hours to keep cached data (None = never expire, default)
458 |
459 | **Returns:** `dict` - System information from server, or None if unavailable
460 |
461 | **Example:**
462 | ```python
463 | # Get system info with default caching (never expires)
464 | system_info = await client.get_system_info()
465 | if system_info:
466 | print(f"RAM: {system_info['Physical Memory']}")
467 | print(f"CPU: {system_info['Processor']}")
468 |
469 | # Get system info with 24-hour cache expiry
470 | system_info = await client.get_system_info(cache_duration_hours=24)
471 |
472 | # Use custom cache directory
473 | system_info = await client.get_system_info(cache_dir="/path/to/cache")
474 | ```
475 |
476 | ---
477 |
478 | #### `select_model_for_hardware(system_info=None, cache_dir=None)` *(Optional)*
479 | Select the optimal model based on hardware capabilities.
480 |
481 | **When to use:** This method is **optional** - use it when you want automatic hardware-based model selection. The selection logic prioritizes larger models for high-end hardware and falls back to smaller models for resource-constrained systems. Developers can skip this method entirely and specify their own model names directly in other LemonadeClient methods.
482 |
483 | **Selection Logic:**
484 | - **64GB+ RAM or discrete GPU with 16GB+ VRAM**: `Qwen3-Coder-30B-A3B-Instruct-GGUF`
485 | - **AMD NPU available**: `Qwen-2.5-7B-Instruct-Hybrid`
486 | - **Default/fallback**: `Qwen3-4B-Instruct-2507-GGUF`
487 |
488 | **Parameters:**
489 | - `system_info: Optional[Dict]` - Pre-fetched system info (if None, will fetch automatically)
490 | - `cache_dir: Optional[str]` - Directory for caching system info (passed to get_system_info)
491 |
492 | **Returns:** `tuple[str, float]` - (model_name, size_gb) based on hardware capabilities
493 |
494 | **Example:**
495 | ```python
496 | # Automatic model selection (optional)
497 | model_name, size_gb = await client.select_model_for_hardware()
498 | print(f"Recommended model: {model_name} ({size_gb} GB)")
499 |
500 | # Use pre-fetched system info
501 | system_info = await client.get_system_info()
502 | model_name, size_gb = await client.select_model_for_hardware(system_info=system_info)
503 |
504 | # Use custom cache directory
505 | model_name, size_gb = await client.select_model_for_hardware(cache_dir="/path/to/cache")
506 |
507 | # Alternative: Skip hardware detection and use your own model
508 | model_name = "My-Custom-Model"
509 | # Then proceed with check_model_installed(), install_model(), etc.
510 | ```
511 |
512 | ---
513 |
514 | ## Error Handling
515 |
516 | Most methods return dictionaries with `success` and `message` keys for operations, or boolean values for status checks. Always check return values:
517 |
518 | ```python
519 | # For operations
520 | result = await client.start_lemonade_server()
521 | if not result["success"]:
522 | print(f"Operation failed: {result['message']}")
523 | return
524 |
525 | # For status checks
526 | if not await client.check_lemonade_server_api():
527 | print("Server API is not responding")
528 | return
529 | ```
530 |
531 | ## Integration Example
532 |
533 | For a complete, runnable example of integrating LemonadeClient into an application, see:
534 |
535 | **[examples/lemonade_client_integration_example.py](../examples/lemonade_client_integration_example.py)**
536 |
537 | This example demonstrates:
538 | - Complete setup workflow from installation to ready-for-inference
539 | - Error handling and status checking
540 | - Model installation and loading
541 | - Basic inference testing
542 | - Proper logging and user feedback
543 |
544 | You can run the example directly to test your LemonadeClient setup:
545 |
546 | ```bash
547 | python infinity-arcade/examples/lemonade_client_integration_example.py
548 | ```
549 |
550 | The example includes comprehensive error handling and step-by-step progress reporting, making it suitable for both learning and as a starting point for your own integration.
551 |
--------------------------------------------------------------------------------
/src/infinity_arcade/main.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | Infinity Arcade - Main FastAPI application
4 | """
5 |
6 | import json
7 | import logging
8 | import os
9 | import re
10 | import subprocess
11 | import sys
12 | import time
13 | import uuid
14 |
15 |
16 | import uvicorn
17 | from fastapi import FastAPI, HTTPException, Request
18 | from fastapi.responses import (
19 | JSONResponse,
20 | StreamingResponse,
21 | RedirectResponse,
22 | )
23 | from fastapi.staticfiles import StaticFiles
24 | from fastapi.templating import Jinja2Templates
25 | from starlette.responses import Response
26 |
27 | import lemonade_client as lc
28 | from infinity_arcade.arcade_games import ArcadeGames
29 | from infinity_arcade.utils import get_resource_path
30 | from infinity_arcade.game_launcher import GameLauncher
31 | from infinity_arcade.game_orchestrator import GameOrchestrator
32 | from infinity_arcade.llm_service import LLMService
33 |
34 | # Minimum required version of lemonade-server
35 | LEMONADE_MINIMUM_VERSION = "9.0.3"
36 |
37 |
38 | # Pygame will be imported on-demand to avoid early DLL loading issues
39 | # pylint: disable=invalid-name
40 | pygame = None
41 |
42 | # Logger will be configured by CLI or set to INFO if run directly
43 | logger = logging.getLogger("infinity_arcade.main")
44 |
45 |
46 | class NoCacheStaticFiles(StaticFiles):
47 | """Custom StaticFiles class with no-cache headers"""
48 |
49 | def __init__(self, *args, **kwargs):
50 | super().__init__(*args, **kwargs)
51 |
52 | def file_response(self, *args, **kwargs) -> Response:
53 | response = super().file_response(*args, **kwargs)
54 | # Add no-cache headers for all static files
55 | response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
56 | response.headers["Pragma"] = "no-cache"
57 | response.headers["Expires"] = "0"
58 | return response
59 |
60 |
61 | class ArcadeApp:
62 | """Encapsulates app state, services, and route registration."""
63 |
64 | def __init__(self) -> None:
65 | # Initialize basic components first
66 | self.lemonade_handle = lc.LemonadeClient(
67 | minimum_version=LEMONADE_MINIMUM_VERSION, logger=logger
68 | )
69 | self.arcade_games = ArcadeGames()
70 | self.game_launcher = GameLauncher()
71 |
72 | # Model will be determined asynchronously
73 | self.required_model = None
74 | self.required_model_size = None
75 | self.llm_service = None
76 | self.orchestrator = None
77 |
78 | # FastAPI app and resources
79 | self.app = FastAPI(title="Infinity Arcade", version="0.1.0")
80 | self.static_dir = get_resource_path("static")
81 | self.templates_dir = get_resource_path("templates")
82 | self.templates = Jinja2Templates(directory=str(self.templates_dir))
83 |
84 | self.app.mount(
85 | "/static", NoCacheStaticFiles(directory=str(self.static_dir)), name="static"
86 | )
87 |
88 | # Register endpoints
89 | self._register_routes()
90 |
91 | async def initialize_model_and_services(self) -> None:
92 | """Initialize model selection and dependent services asynchronously."""
93 | # Determine required model
94 | if os.environ.get("INFINITY_ARCADE_MODEL"):
95 | self.required_model = os.environ.get("INFINITY_ARCADE_MODEL")
96 | self.required_model_size = None # Unknown size for custom models
97 | logger.info(f"Using model from environment variable: {self.required_model}")
98 | else:
99 | # Use hardware-based selection with caching in the arcade directory
100 | cache_dir = str(self.arcade_games.games_dir.parent) # ~/.infinity-arcade
101 | self.required_model, self.required_model_size = (
102 | await self.lemonade_handle.select_model_for_hardware(
103 | cache_dir=cache_dir
104 | )
105 | )
106 | logger.info(
107 | f"Selected model based on hardware: {self.required_model} ({self.required_model_size} GB)"
108 | )
109 |
110 | # Initialize services that depend on the model
111 | self.llm_service = LLMService(self.lemonade_handle, self.required_model)
112 | self.orchestrator = GameOrchestrator(
113 | self.arcade_games, self.game_launcher, self.llm_service
114 | )
115 |
116 | @staticmethod
117 | def generate_game_id() -> str:
118 | return str(uuid.uuid4())[:8]
119 |
120 | @staticmethod
121 | def generate_next_version_title(original_title: str) -> str:
122 | version_match = re.search(r" v(\d+)$", original_title)
123 | if version_match:
124 | current_version = int(version_match.group(1))
125 | next_version = current_version + 1
126 | base_title = original_title[: version_match.start()]
127 | return f"{base_title} v{next_version}"
128 | else:
129 | return f"{original_title} v2"
130 |
131 | def _register_routes(self) -> None:
132 | @self.app.get("/")
133 | async def root(request: Request):
134 | return self.templates.TemplateResponse("index.html", {"request": request})
135 |
136 | @self.app.get("/favicon.ico")
137 | async def favicon():
138 | return RedirectResponse(url="/static/favicon.ico")
139 |
140 | @self.app.get("/api/server-status")
141 | async def server_status():
142 | online = await self.lemonade_handle.check_lemonade_server_api()
143 | return JSONResponse({"online": online})
144 |
145 | @self.app.get("/api/selected-model")
146 | async def selected_model():
147 | # Initialize model selection if not done yet
148 | if self.required_model is None:
149 | await self.initialize_model_and_services()
150 |
151 | response = {"model_name": self.required_model}
152 | if self.required_model_size is not None:
153 | response["size_gb"] = self.required_model_size
154 | response["size_display"] = f"{self.required_model_size} GB"
155 | return JSONResponse(response)
156 |
157 | @self.app.get("/api/games")
158 | async def get_games():
159 | self.game_launcher.cleanup_finished_games()
160 | return JSONResponse(self.arcade_games.game_metadata)
161 |
162 | @self.app.get("/api/installation-status")
163 | async def installation_status():
164 | logger.info("Installation status endpoint called")
165 | version_info = await self.lemonade_handle.check_lemonade_server_version()
166 | logger.info(f"Version check result: {version_info}")
167 | result = {
168 | "installed": version_info["installed"],
169 | "version": version_info["version"],
170 | "compatible": version_info["compatible"],
171 | "required_version": version_info["required_version"],
172 | }
173 | logger.info(f"Returning installation status: {result}")
174 | return JSONResponse(result)
175 |
176 | @self.app.get("/api/server-running-status")
177 | async def server_running_status():
178 | logger.info("=== Server running status endpoint called ===")
179 | is_running = await self.lemonade_handle.check_lemonade_server_running()
180 | logger.info(f"Initial running check result: {is_running}")
181 | if not is_running:
182 | logger.info("Server not running, attempting to start automatically...")
183 | start_result = await self.lemonade_handle.start_lemonade_server()
184 | logger.info(f"Auto-start result: {start_result}")
185 | if start_result["success"]:
186 | import asyncio
187 |
188 | logger.info("Waiting 2 seconds for server to initialize...")
189 | await asyncio.sleep(2)
190 | is_running = (
191 | await self.lemonade_handle.check_lemonade_server_running()
192 | )
193 | logger.info(f"Running check after auto-start: {is_running}")
194 | else:
195 | logger.warning(
196 | f"Auto-start failed: {start_result.get('error', 'Unknown error')}"
197 | )
198 | result = {"running": is_running}
199 | logger.info(f"=== Returning server running status: {result} ===")
200 | return JSONResponse(result)
201 |
202 | @self.app.get("/api/api-connection-status")
203 | async def api_connection_status():
204 | logger.info("=== API connection status endpoint called ===")
205 | api_online = await self.lemonade_handle.check_lemonade_server_api()
206 | logger.info(f"API online check result: {api_online}")
207 | result = {"api_online": api_online}
208 | logger.info(f"=== Returning API connection status: {result} ===")
209 | return JSONResponse(result)
210 |
211 | @self.app.get("/api/model-installation-status")
212 | async def model_installation_status():
213 | model_status = await self.lemonade_handle.check_model_installed(
214 | self.required_model
215 | )
216 | result = {
217 | "model_installed": model_status["installed"],
218 | "model_name": model_status["model_name"],
219 | }
220 | logger.info(f"Returning model installation status: {result}")
221 | return JSONResponse(result)
222 |
223 | @self.app.get("/api/model-loading-status")
224 | async def model_loading_status():
225 | logger.info("Model loading status endpoint called")
226 | model_loaded_status = await self.lemonade_handle.check_model_loaded(
227 | self.required_model
228 | )
229 | logger.info(f"Model loaded check result: {model_loaded_status}")
230 | result = {
231 | "model_loaded": model_loaded_status["loaded"],
232 | "model_name": model_loaded_status["model_name"],
233 | "current_model": model_loaded_status["current_model"],
234 | }
235 | logger.info(f"Returning model loading status: {result}")
236 | return JSONResponse(result)
237 |
238 | @self.app.get("/api/installation-environment")
239 | async def installation_environment():
240 | logger.info("Installation environment endpoint called")
241 | is_pyinstaller = self.lemonade_handle.is_pyinstaller_environment()
242 | # Determine preferred installation method based on platform
243 | if sys.platform == "win32":
244 | preferred_method = "installer"
245 | else:
246 | preferred_method = "manual"
247 |
248 | result = {
249 | "is_pyinstaller": is_pyinstaller,
250 | "platform": sys.platform,
251 | "preferred_method": preferred_method,
252 | }
253 | logger.info(f"Returning installation environment: {result}")
254 | return JSONResponse(result)
255 |
256 | @self.app.post("/api/refresh-environment")
257 | async def refresh_environment_endpoint():
258 | logger.info("Refresh environment endpoint called")
259 | try:
260 | self.lemonade_handle.refresh_environment()
261 | self.lemonade_handle.reset_server_state()
262 | return JSONResponse(
263 | {"success": True, "message": "Environment refreshed"}
264 | )
265 | except Exception as e:
266 | logger.error(f"Failed to refresh environment: {e}")
267 | return JSONResponse(
268 | {"success": False, "message": f"Failed to refresh environment: {e}"}
269 | )
270 |
271 | @self.app.post("/api/install-server")
272 | async def install_server():
273 | logger.info("Install server endpoint called")
274 | result = await self.lemonade_handle.download_and_install_lemonade_server()
275 | logger.info(f"Install result: {result}")
276 | return JSONResponse(result)
277 |
278 | @self.app.post("/api/start-server")
279 | async def start_server():
280 | logger.info("Start server endpoint called")
281 | result = await self.lemonade_handle.start_lemonade_server()
282 | logger.info(f"Start server result: {result}")
283 | return JSONResponse(result)
284 |
285 | @self.app.post("/api/install-model")
286 | async def install_model():
287 | logger.info("Install model endpoint called")
288 | result = await self.lemonade_handle.install_model(self.required_model)
289 | logger.info(f"Install model result: {result}")
290 | return JSONResponse(result)
291 |
292 | @self.app.post("/api/load-model")
293 | async def load_model():
294 | logger.info("Load model endpoint called")
295 | result = await self.lemonade_handle.load_model(self.required_model)
296 | logger.info(f"Load model result: {result}")
297 | return JSONResponse(result)
298 |
299 | @self.app.post("/api/create-game")
300 | async def create_game_endpoint(request: Request):
301 | logger.debug("Starting game creation endpoint")
302 | data = await request.json()
303 | prompt = data.get("prompt", "")
304 | logger.debug(f"Received request - prompt: '{prompt[:50]}...'")
305 | if not prompt:
306 | logger.error("No prompt provided")
307 | raise HTTPException(status_code=400, detail="Prompt is required")
308 | game_id = self.generate_game_id()
309 | logger.debug(f"Generated game ID: {game_id}")
310 |
311 | async def generate():
312 | try:
313 | async for (
314 | stream_item
315 | ) in self.orchestrator.create_and_launch_game_with_streaming(
316 | game_id, prompt
317 | ):
318 | yield stream_item
319 | except Exception as e:
320 | logger.exception(f"Error in game creation: {e}")
321 | yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
322 |
323 | return StreamingResponse(
324 | generate(),
325 | media_type="text/plain",
326 | headers={
327 | "Cache-Control": "no-cache",
328 | "Connection": "keep-alive",
329 | "Content-Type": "text/plain; charset=utf-8",
330 | },
331 | )
332 |
333 | @self.app.post("/api/remix-game")
334 | async def remix_game_endpoint(request: Request):
335 | logger.debug("Starting game remix endpoint")
336 | data = await request.json()
337 | game_id = data.get("game_id", "")
338 | remix_prompt = data.get("remix_prompt", "")
339 | logger.debug(
340 | f"Received remix request - game_id: '{game_id}', remix_prompt: '{remix_prompt[:50]}...'"
341 | )
342 | if not game_id or not remix_prompt:
343 | logger.error("Game ID and remix prompt are required")
344 | raise HTTPException(
345 | status_code=400, detail="Game ID and remix prompt are required"
346 | )
347 | if game_id not in self.arcade_games.game_metadata:
348 | logger.error(f"Game not found: {game_id}")
349 | raise HTTPException(status_code=404, detail="Game not found")
350 | if game_id in self.arcade_games.builtin_games:
351 | logger.error(f"Cannot remix built-in game: {game_id}")
352 | raise HTTPException(
353 | status_code=403, detail="Cannot remix built-in games"
354 | )
355 | new_game_id = self.generate_game_id()
356 | logger.debug(f"Generated new game ID for remix: {new_game_id}")
357 |
358 | async def generate():
359 | try:
360 | original_title = self.arcade_games.game_metadata[game_id].get(
361 | "title", "Untitled Game"
362 | )
363 | new_title = self.generate_next_version_title(original_title)
364 | async for (
365 | stream_item
366 | ) in self.orchestrator.remix_and_launch_game_with_streaming(
367 | game_id, new_game_id, remix_prompt, new_title
368 | ):
369 | yield stream_item
370 | except Exception as e:
371 | logger.exception(f"Error in game remix: {e}")
372 | yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
373 |
374 | return StreamingResponse(
375 | generate(),
376 | media_type="text/plain",
377 | headers={
378 | "Cache-Control": "no-cache",
379 | "Connection": "keep-alive",
380 | "Content-Type": "text/plain; charset=utf-8",
381 | },
382 | )
383 |
384 | @self.app.post("/api/launch-game/{game_id}")
385 | async def launch_game_endpoint(game_id: str):
386 | self.game_launcher.cleanup_finished_games()
387 | if self.game_launcher.running_games:
388 | raise HTTPException(
389 | status_code=400, detail="Another game is already running"
390 | )
391 | if game_id not in self.arcade_games.game_metadata:
392 | raise HTTPException(status_code=404, detail="Game not found")
393 | game_title = self.arcade_games.game_metadata.get(game_id, {}).get(
394 | "title", game_id
395 | )
396 |
397 | async def generate():
398 | try:
399 | async for (
400 | stream_item
401 | ) in self.orchestrator.launch_game_with_auto_fix_streaming(
402 | game_id, game_title, max_retries=1
403 | ):
404 | yield stream_item
405 | except Exception as e:
406 | logger.exception(f"Error in game launch: {e}")
407 | yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
408 |
409 | return StreamingResponse(
410 | generate(),
411 | media_type="text/plain",
412 | headers={
413 | "Cache-Control": "no-cache",
414 | "Connection": "keep-alive",
415 | "Content-Type": "text/plain; charset=utf-8",
416 | },
417 | )
418 |
419 | @self.app.get("/api/game-status/{game_id}")
420 | async def game_status(game_id: str):
421 | self.game_launcher.cleanup_finished_games()
422 | running = game_id in self.game_launcher.running_games
423 | return JSONResponse({"running": running})
424 |
425 | @self.app.delete("/api/delete-game/{game_id}")
426 | async def delete_game_endpoint(game_id: str):
427 | if game_id not in self.arcade_games.game_metadata:
428 | raise HTTPException(status_code=404, detail="Game not found")
429 | if game_id in self.arcade_games.builtin_games:
430 | raise HTTPException(
431 | status_code=403, detail="Cannot delete built-in games"
432 | )
433 | if game_id in self.game_launcher.running_games:
434 | self.game_launcher.stop_game(game_id)
435 | game_file = self.arcade_games.games_dir / f"{game_id}.py"
436 | if game_file.exists():
437 | game_file.unlink()
438 | del self.arcade_games.game_metadata[game_id]
439 | self.arcade_games.save_metadata()
440 | return JSONResponse({"success": True})
441 |
442 | @self.app.get("/api/game-metadata/{game_id}")
443 | async def get_game_metadata(game_id: str):
444 | if game_id not in self.arcade_games.game_metadata:
445 | raise HTTPException(status_code=404, detail="Game not found")
446 | metadata = self.arcade_games.game_metadata[game_id].copy()
447 | if game_id in self.arcade_games.builtin_games:
448 | metadata.pop("prompt", None)
449 | metadata["builtin"] = True
450 | return JSONResponse(metadata)
451 |
452 | @self.app.post("/api/open-game-file/{game_id}")
453 | async def open_game_file(game_id: str):
454 | if game_id not in self.arcade_games.game_metadata:
455 | raise HTTPException(status_code=404, detail="Game not found")
456 | if game_id in self.arcade_games.builtin_games:
457 | raise HTTPException(
458 | status_code=403, detail="Cannot view source code of built-in games"
459 | )
460 | game_file = self.arcade_games.games_dir / f"{game_id}.py"
461 | if not game_file.exists():
462 | raise HTTPException(status_code=404, detail="Game file not found")
463 | try:
464 | if sys.platform.startswith("win"):
465 | subprocess.run(["start", str(game_file)], shell=True, check=True)
466 | elif sys.platform.startswith("darwin"):
467 | subprocess.run(["open", str(game_file)], check=True)
468 | else:
469 | subprocess.run(["xdg-open", str(game_file)], check=True)
470 | return JSONResponse({"success": True, "message": "File opened"})
471 | except Exception as e:
472 | logger.error(f"Failed to open file {game_file}: {e}")
473 | raise HTTPException(
474 | status_code=500, detail=f"Failed to open file: {str(e)}"
475 | ) from e
476 |
477 |
478 | arcade_app = ArcadeApp()
479 | app = arcade_app.app
480 |
481 |
482 | def run_game_file(game_file_path):
483 | """Run a game file directly - used when executable is called with a game file."""
484 | try:
485 | print(f"Infinity Arcade - Running game: {game_file_path}")
486 |
487 | # Import pygame here, right before we need it
488 | # pylint: disable=global-statement
489 | global pygame
490 | if pygame is None:
491 | try:
492 | # pylint: disable=redefined-outer-name
493 | import pygame
494 |
495 | print(f"Pygame {pygame.version.ver} loaded successfully")
496 | except ImportError as e:
497 | print(f"Error: Failed to import pygame: {e}")
498 | sys.exit(1)
499 |
500 | # Read and execute the game file
501 | with open(game_file_path, "r", encoding="utf-8") as f:
502 | game_code = f.read()
503 |
504 | # Execute the game code - pygame should now be available
505 | # pylint: disable=exec-used
506 | exec(game_code, {"__name__": "__main__", "__file__": game_file_path})
507 |
508 | except Exception as e:
509 | print(f"Error running game {game_file_path}: {e}")
510 | import traceback
511 |
512 | traceback.print_exc()
513 | sys.exit(1)
514 |
515 |
516 | def main():
517 | """Main entry point for the application."""
518 | # Configure logging if not already configured (when run directly, not via CLI)
519 | if not logging.getLogger().handlers:
520 | logging.basicConfig(
521 | level=logging.INFO,
522 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
523 | )
524 | # Suppress noisy httpcore debug messages
525 | logging.getLogger("httpcore").setLevel(logging.WARNING)
526 | logging.getLogger("httpx").setLevel(logging.WARNING)
527 |
528 | # Check if we're being called to run a specific game file
529 | if len(sys.argv) == 2 and sys.argv[1].endswith(".py"):
530 | # Game mode: run the specified game file
531 | run_game_file(sys.argv[1])
532 | return
533 |
534 | # Server mode: start the Infinity Arcade server
535 | import webbrowser
536 | import threading
537 |
538 | # Keep console visible for debugging and control
539 | print("Starting Infinity Arcade...")
540 | print("Press Ctrl+C to quit")
541 |
542 | port = 8081
543 |
544 | # Start the server in a separate thread
545 | def run_server():
546 | print(f"Starting Infinity Arcade server on http://127.0.0.1:{port}")
547 | try:
548 | uvicorn.run(app, host="127.0.0.1", port=port, log_level="info")
549 | except Exception as e:
550 | print(f"Error starting server: {e}")
551 |
552 | print("Launching server thread...")
553 | server_thread = threading.Thread(target=run_server, daemon=True)
554 | server_thread.start()
555 |
556 | # Wait a moment then open browser
557 | print("Waiting for server to start...")
558 | time.sleep(3)
559 | print(f"Opening browser to http://127.0.0.1:{port}")
560 | webbrowser.open(f"http://127.0.0.1:{port}")
561 |
562 | try:
563 | # Keep the main thread alive
564 | while True:
565 | time.sleep(1)
566 | except KeyboardInterrupt:
567 | print("\nShutting down Infinity Arcade...")
568 | # Clean up any running games
569 | for game_id in list(arcade_app.game_launcher.running_games.keys()):
570 | arcade_app.game_launcher.stop_game(game_id)
571 |
572 |
573 | if __name__ == "__main__":
574 | main()
575 |
576 | # Copyright (c) 2025 AMD
577 |
--------------------------------------------------------------------------------
/src/infinity_arcade/static/style.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | box-sizing: border-box;
5 | }
6 |
7 | body {
8 | font-family: 'Courier New', monospace;
9 | background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%);
10 | color: #00ff41;
11 | min-height: 100vh;
12 | overflow-x: hidden;
13 | }
14 |
15 | .container {
16 | display: grid;
17 | grid-template-columns: 2fr 1fr;
18 | grid-template-rows: auto 1fr auto;
19 | height: 100vh;
20 | gap: 20px;
21 | padding: 20px;
22 | overflow: hidden;
23 | }
24 |
25 | .header {
26 | grid-column: 1 / -1;
27 | text-align: center;
28 | position: relative;
29 | }
30 |
31 | .title {
32 | font-size: 0.7rem;
33 | font-weight: bold;
34 | text-shadow: 0 0 10px #00ff41, 0 0 20px #00ff41;
35 | letter-spacing: 1px;
36 | margin: 15px 0;
37 | font-family: monospace;
38 | line-height: 1.0;
39 | }
40 |
41 | .title pre {
42 | margin: 0;
43 | padding: 0;
44 | font-family: inherit;
45 | font-size: inherit;
46 | font-weight: inherit;
47 | text-shadow: inherit;
48 | letter-spacing: inherit;
49 | line-height: inherit;
50 | color: inherit;
51 | }
52 |
53 | .server-status {
54 | position: absolute;
55 | top: 10px;
56 | right: 10px;
57 | display: flex;
58 | align-items: center;
59 | gap: 10px;
60 | background: rgba(0, 0, 0, 0.7);
61 | padding: 10px 15px;
62 | border-radius: 25px;
63 | border: 2px solid #00ff41;
64 | }
65 |
66 | .status-indicator {
67 | width: 12px;
68 | height: 12px;
69 | border-radius: 50%;
70 | animation: pulse 2s infinite;
71 | }
72 |
73 | .status-online {
74 | background: #00ff41;
75 | box-shadow: 0 0 10px #00ff41;
76 | }
77 |
78 | .status-offline {
79 | background: #ff4444;
80 | box-shadow: 0 0 10px #ff4444;
81 | }
82 |
83 | @keyframes pulse {
84 | 0%, 100% { opacity: 1; }
85 | 50% { opacity: 0.5; }
86 | }
87 |
88 | .main-content {
89 | display: flex;
90 | flex-direction: column;
91 | gap: 20px;
92 | min-height: 0; /* Allow flexbox to work properly */
93 | height: 100%; /* Ensure it fills the full grid area height */
94 | }
95 |
96 | .game-interface {
97 | display: flex;
98 | flex-direction: column;
99 | flex: 1; /* Take all available space */
100 | min-height: 0; /* Allow flexbox to work properly */
101 | height: 100%; /* Ensure it fills the available height */
102 | }
103 |
104 | .game-library {
105 | background: rgba(0, 0, 0, 0.8);
106 | border: 2px solid #00ff41;
107 | border-radius: 10px;
108 | padding: 20px;
109 | overflow-y: auto;
110 | display: flex;
111 | flex-direction: column;
112 | flex: 1;
113 | min-height: 0; /* Allow scrolling to work */
114 | height: 100%; /* Ensure it fills the full grid area height */
115 | }
116 |
117 | .library-title {
118 | font-size: 1.5rem;
119 | margin-bottom: 20px;
120 | text-align: center;
121 | text-transform: uppercase;
122 | letter-spacing: 2px;
123 | }
124 |
125 | .games-grid {
126 | display: grid;
127 | grid-template-columns: repeat(6, 1fr);
128 | gap: 12px;
129 | overflow-y: auto;
130 | flex: 1;
131 | padding: 10px 0;
132 | align-content: start;
133 | }
134 |
135 | /* Custom scrollbar styling for games grid */
136 | .games-grid::-webkit-scrollbar {
137 | width: 8px;
138 | }
139 |
140 | .games-grid::-webkit-scrollbar-track {
141 | background: #0f0f23;
142 | border-radius: 4px;
143 | }
144 |
145 | .games-grid::-webkit-scrollbar-thumb {
146 | background: linear-gradient(to bottom, #00ff41, #00cc33);
147 | border-radius: 4px;
148 | border: 1px solid #0a1a0a;
149 | }
150 |
151 | .games-grid::-webkit-scrollbar-thumb:hover {
152 | background: linear-gradient(to bottom, #33ff66, #00ff41);
153 | }
154 |
155 | .game-item {
156 | background: linear-gradient(145deg, #1a1a2e, #16213e);
157 | border: 2px solid #00ff41;
158 | border-radius: 12px;
159 | padding: 15px;
160 | text-align: center;
161 | cursor: pointer;
162 | transition: all 0.3s ease;
163 | position: relative;
164 | overflow: hidden;
165 | aspect-ratio: 1;
166 | min-height: 120px;
167 | max-height: 140px;
168 | display: flex;
169 | flex-direction: column;
170 | justify-content: center;
171 | }
172 |
173 | .game-item:hover {
174 | transform: translateY(-3px);
175 | box-shadow: 0 8px 25px rgba(0, 255, 65, 0.3);
176 | border-color: #00ffff;
177 | }
178 |
179 | .game-item.running {
180 | border-color: #ffff00;
181 | background: linear-gradient(145deg, #2e2e1a, #3e3e16);
182 | }
183 |
184 | .game-item.builtin {
185 | border-color: #ffd700;
186 | background: linear-gradient(145deg, #2e2e1a, #3e3e16);
187 | }
188 |
189 | .game-item.builtin:hover {
190 | transform: translateY(-3px);
191 | box-shadow: 0 8px 25px rgba(255, 215, 0, 0.3);
192 | border-color: #ffff00;
193 | }
194 |
195 | .game-item.builtin .delete-btn {
196 | display: none; /* Hide delete button for built-in games */
197 | }
198 |
199 | .delete-btn {
200 | position: absolute;
201 | top: 5px;
202 | right: 5px;
203 | width: 25px;
204 | height: 25px;
205 | background: #ff4444;
206 | border: none;
207 | border-radius: 50%;
208 | color: white;
209 | cursor: pointer;
210 | opacity: 0;
211 | transition: opacity 0.3s ease;
212 | font-size: 14px;
213 | display: flex;
214 | align-items: center;
215 | justify-content: center;
216 | }
217 |
218 | .game-item:hover .delete-btn {
219 | opacity: 1;
220 | }
221 |
222 | .delete-btn:hover {
223 | background: #ff0000;
224 | }
225 |
226 | .game-title {
227 | font-size: 1rem;
228 | font-weight: bold;
229 | text-align: center;
230 | word-wrap: break-word;
231 | line-height: 1.3;
232 | color: #00ff41;
233 | }
234 |
235 | .sidecar {
236 | background: rgba(0, 0, 0, 0.9);
237 | border: 2px solid #00ff41;
238 | border-radius: 10px;
239 | padding: 20px;
240 | display: flex;
241 | flex-direction: column;
242 | overflow: hidden;
243 | min-height: 0; /* Allow flexbox to work properly */
244 | height: 100%; /* Ensure it fills the full grid area height */
245 | }
246 |
247 | .sidecar-title {
248 | font-size: 1.2rem;
249 | margin-bottom: 15px;
250 | text-align: center;
251 | text-transform: uppercase;
252 | letter-spacing: 1px;
253 | }
254 |
255 | .llm-output {
256 | flex: 1;
257 | background: #000;
258 | border: 1px solid #333;
259 | border-radius: 5px;
260 | padding: 15px;
261 | overflow-y: auto;
262 | font-size: 0.9rem;
263 | line-height: 1.5;
264 | word-wrap: break-word;
265 | white-space: pre-wrap;
266 | }
267 |
268 | /* Override white-space for rendered markdown content */
269 | .llm-output.markdown-content {
270 | white-space: normal;
271 | }
272 |
273 | /* Markdown formatting styles */
274 | .llm-output h1, .llm-output h2, .llm-output h3, .llm-output h4, .llm-output h5, .llm-output h6 {
275 | color: #00ffff;
276 | margin: 15px 0 10px 0;
277 | font-weight: bold;
278 | }
279 |
280 | .llm-output h1 { font-size: 1.5rem; }
281 | .llm-output h2 { font-size: 1.3rem; }
282 | .llm-output h3 { font-size: 1.1rem; }
283 |
284 | .llm-output p {
285 | margin: 10px 0;
286 | color: #00ff41;
287 | }
288 |
289 | .llm-output code {
290 | background: #1a1a2e;
291 | color: #ffff99;
292 | padding: 2px 6px;
293 | border-radius: 3px;
294 | font-family: 'Courier New', monospace;
295 | font-size: 0.9em;
296 | }
297 |
298 | .llm-output pre {
299 | background: #0f0f23;
300 | border: 2px solid #333;
301 | border-radius: 8px;
302 | padding: 15px;
303 | margin: 15px 0;
304 | overflow-x: auto;
305 | white-space: pre;
306 | position: relative;
307 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
308 | }
309 |
310 | /* Custom horizontal scrollbar styling for code blocks */
311 | .llm-output pre::-webkit-scrollbar {
312 | height: 8px;
313 | }
314 |
315 | .llm-output pre::-webkit-scrollbar-track {
316 | background: #0f0f23;
317 | border-radius: 4px;
318 | }
319 |
320 | .llm-output pre::-webkit-scrollbar-thumb {
321 | background: linear-gradient(to right, #00ff41, #00cc33);
322 | border-radius: 4px;
323 | border: 1px solid #0a1a0a;
324 | }
325 |
326 | .llm-output pre::-webkit-scrollbar-thumb:hover {
327 | background: linear-gradient(to right, #33ff66, #00ff41);
328 | }
329 |
330 | .llm-output pre code {
331 | background: none;
332 | padding: 0;
333 | color: #00ff41;
334 | font-size: 0.85rem;
335 | line-height: 1.4;
336 | font-family: 'Courier New', 'Lucida Console', monospace;
337 | display: block;
338 | white-space: pre;
339 | }
340 |
341 | /* Hide empty code blocks */
342 | .llm-output pre:empty,
343 | .llm-output pre code:empty {
344 | display: none;
345 | }
346 |
347 | /* Enhanced Python syntax highlighting for code blocks */
348 | .llm-output pre code.language-python {
349 | color: #00ff41;
350 | }
351 |
352 | .llm-output pre::before {
353 | content: attr(data-lang);
354 | position: absolute;
355 | top: 8px;
356 | right: 12px;
357 | background: linear-gradient(145deg, #00ff41, #00cc33);
358 | color: #000;
359 | padding: 4px 10px;
360 | border-radius: 4px;
361 | font-size: 0.7rem;
362 | font-weight: bold;
363 | text-transform: uppercase;
364 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
365 | z-index: 1;
366 | }
367 |
368 | .llm-output pre[data-lang="python"]::before {
369 | content: "🐍 Python";
370 | }
371 |
372 | .llm-output ul, .llm-output ol {
373 | margin: 10px 0;
374 | padding-left: 20px;
375 | color: #00ff41;
376 | }
377 |
378 | .llm-output li {
379 | margin: 5px 0;
380 | }
381 |
382 | .llm-output blockquote {
383 | border-left: 4px solid #00ff41;
384 | margin: 15px 0;
385 | padding: 10px 15px;
386 | background: rgba(0, 255, 65, 0.1);
387 | font-style: italic;
388 | }
389 |
390 | .llm-output strong {
391 | color: #00ffff;
392 | font-weight: bold;
393 | }
394 |
395 | .llm-output em {
396 | color: #ffff99;
397 | font-style: italic;
398 | }
399 |
400 | .llm-output a {
401 | color: #00ffff;
402 | text-decoration: underline;
403 | }
404 |
405 | .llm-output a:hover {
406 | color: #33ffff;
407 | }
408 |
409 | .llm-output table {
410 | border-collapse: collapse;
411 | margin: 15px 0;
412 | color: #00ff41;
413 | }
414 |
415 | .llm-output th, .llm-output td {
416 | border: 1px solid #333;
417 | padding: 8px 12px;
418 | text-align: left;
419 | }
420 |
421 | .llm-output th {
422 | background: #1a1a2e;
423 | color: #00ffff;
424 | font-weight: bold;
425 | }
426 |
427 | .llm-output hr {
428 | border: none;
429 | border-top: 2px solid #333;
430 | margin: 20px 0;
431 | }
432 |
433 | /* Custom scrollbar styling for LLM output */
434 | .llm-output::-webkit-scrollbar {
435 | width: 8px;
436 | }
437 |
438 | .llm-output::-webkit-scrollbar-track {
439 | background: #0f0f23;
440 | border-radius: 4px;
441 | }
442 |
443 | .llm-output::-webkit-scrollbar-thumb {
444 | background: linear-gradient(to bottom, #00ff41, #00cc33);
445 | border-radius: 4px;
446 | border: 1px solid #0a1a0a;
447 | }
448 |
449 | .llm-output::-webkit-scrollbar-thumb:hover {
450 | background: linear-gradient(to bottom, #33ff66, #00ff41);
451 | }
452 |
453 | .input-area {
454 | grid-column: 1 / -1;
455 | background: rgba(0, 0, 0, 0.8);
456 | border: 2px solid #00ff41;
457 | border-radius: 10px;
458 | padding: 20px;
459 | display: flex;
460 | flex-direction: column;
461 | gap: 15px;
462 | }
463 |
464 | .input-controls {
465 | display: flex;
466 | gap: 15px;
467 | align-items: center;
468 | }
469 |
470 | /* Remix Mode Game Title Box */
471 | .remix-game-title {
472 | background: rgba(0, 255, 65, 0.1);
473 | border: 2px solid #00ff41;
474 | border-radius: 8px;
475 | padding: 12px 15px;
476 | display: flex;
477 | align-items: center;
478 | justify-content: space-between;
479 | margin-bottom: 10px;
480 | animation: remix-glow 2s ease-in-out infinite alternate;
481 | }
482 |
483 | @keyframes remix-glow {
484 | 0% { box-shadow: 0 0 10px rgba(0, 255, 65, 0.3); }
485 | 100% { box-shadow: 0 0 20px rgba(0, 255, 65, 0.5); }
486 | }
487 |
488 | .remix-title-text {
489 | color: #00ff41;
490 | font-weight: bold;
491 | font-size: 1.1rem;
492 | text-shadow: 0 0 5px rgba(0, 255, 65, 0.5);
493 | }
494 |
495 | .remix-exit-btn {
496 | background: #ff4444;
497 | border: none;
498 | border-radius: 50%;
499 | color: white;
500 | width: 28px;
501 | height: 28px;
502 | cursor: pointer;
503 | font-size: 18px;
504 | font-weight: bold;
505 | display: flex;
506 | align-items: center;
507 | justify-content: center;
508 | transition: all 0.3s ease;
509 | }
510 |
511 | .remix-exit-btn:hover {
512 | background: #ff0000;
513 | transform: scale(1.1);
514 | box-shadow: 0 0 10px rgba(255, 68, 68, 0.5);
515 | }
516 |
517 | .model-select {
518 | background: #1a1a2e;
519 | border: 2px solid #00ff41;
520 | border-radius: 5px;
521 | color: #00ff41;
522 | padding: 12px;
523 | font-family: inherit;
524 | min-width: 200px;
525 | }
526 |
527 | .prompt-input {
528 | flex: 1;
529 | background: #000;
530 | border: 2px solid #00ff41;
531 | border-radius: 5px;
532 | color: #00ff41;
533 | padding: 12px;
534 | font-family: inherit;
535 | font-size: 1rem;
536 | }
537 |
538 | .prompt-input:focus {
539 | outline: none;
540 | box-shadow: 0 0 10px rgba(0, 255, 65, 0.5);
541 | }
542 |
543 | .create-btn {
544 | background: linear-gradient(145deg, #00ff41, #00cc33);
545 | border: none;
546 | border-radius: 5px;
547 | color: #000;
548 | padding: 12px 25px;
549 | font-family: inherit;
550 | font-weight: bold;
551 | cursor: pointer;
552 | transition: all 0.3s ease;
553 | text-transform: uppercase;
554 | letter-spacing: 1px;
555 | }
556 |
557 | .create-btn:hover {
558 | transform: translateY(-2px);
559 | box-shadow: 0 5px 15px rgba(0, 255, 65, 0.4);
560 | }
561 |
562 | .create-btn:disabled {
563 | background: #333;
564 | color: #666;
565 | cursor: not-allowed;
566 | transform: none;
567 | box-shadow: none;
568 | }
569 |
570 | .spinner {
571 | display: none;
572 | text-align: center;
573 | padding: 20px;
574 | }
575 |
576 | .spinner.active {
577 | display: block;
578 | }
579 |
580 | .spinner-circle {
581 | width: 40px;
582 | height: 40px;
583 | border: 4px solid #333;
584 | border-top: 4px solid #00ff41;
585 | border-radius: 50%;
586 | animation: spin 1s linear infinite;
587 | margin: 0 auto 10px;
588 | }
589 |
590 | @keyframes spin {
591 | 0% { transform: rotate(0deg); }
592 | 100% { transform: rotate(360deg); }
593 | }
594 |
595 | .status-text {
596 | font-size: 1.1rem;
597 | color: #00ff41;
598 | margin-top: 10px;
599 | }
600 |
601 | .error-message {
602 | background: rgba(255, 68, 68, 0.1);
603 | border: 2px solid #ff4444;
604 | border-radius: 5px;
605 | color: #ff4444;
606 | padding: 15px;
607 | margin: 10px 0;
608 | }
609 |
610 | .llm-output .error-message {
611 | background: rgba(255, 68, 68, 0.2);
612 | border: 1px solid #ff4444;
613 | border-radius: 5px;
614 | color: #ff4444;
615 | padding: 10px;
616 | margin: 0;
617 | }
618 |
619 | .error-bubble {
620 | background: linear-gradient(135deg, #ff4444, #cc3333);
621 | border: 2px solid #ff6666;
622 | border-radius: 15px;
623 | color: white;
624 | padding: 15px 20px;
625 | margin: 15px 0;
626 | text-align: center;
627 | font-weight: bold;
628 | box-shadow: 0 4px 12px rgba(255, 68, 68, 0.3);
629 | animation: errorPulse 2s ease-in-out infinite;
630 | position: relative;
631 | overflow: hidden;
632 | }
633 |
634 | .error-bubble::before {
635 | content: '';
636 | position: absolute;
637 | top: 0;
638 | left: -100%;
639 | width: 100%;
640 | height: 100%;
641 | background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
642 | animation: errorShimmer 3s ease-in-out infinite;
643 | }
644 |
645 | @keyframes errorPulse {
646 | 0%, 100% {
647 | box-shadow: 0 4px 12px rgba(255, 68, 68, 0.3);
648 | transform: scale(1);
649 | }
650 | 50% {
651 | box-shadow: 0 6px 18px rgba(255, 68, 68, 0.5);
652 | transform: scale(1.02);
653 | }
654 | }
655 |
656 | @keyframes errorShimmer {
657 | 0% { left: -100%; }
658 | 100% { left: 100%; }
659 | }
660 |
661 | .get-lemonade-link {
662 | color: #00ffff;
663 | text-decoration: none;
664 | font-weight: bold;
665 | }
666 |
667 | .get-lemonade-link:hover {
668 | text-decoration: underline;
669 | }
670 |
671 | .empty-library {
672 | text-align: center;
673 | color: #666;
674 | font-style: italic;
675 | padding: 40px;
676 | }
677 |
678 | /* New User Experience - Full Screen Setup */
679 | .setup-screen {
680 | position: fixed;
681 | top: 0;
682 | left: 0;
683 | width: 100%;
684 | height: 100%;
685 | background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%);
686 | display: flex;
687 | flex-direction: column;
688 | align-items: center;
689 | justify-content: center;
690 | z-index: 1000;
691 | padding: 20px;
692 | box-sizing: border-box;
693 | font-family: 'Courier New', monospace;
694 | overflow-y: auto;
695 | }
696 |
697 | .setup-container {
698 | max-width: 700px;
699 | width: 100%;
700 | background: rgba(0, 0, 0, 0.8);
701 | border-radius: 10px;
702 | border: 2px solid #00ff41;
703 | position: relative;
704 | padding: 40px;
705 | box-shadow:
706 | 0 0 20px rgba(0, 255, 65, 0.3),
707 | inset 0 0 20px rgba(0, 255, 65, 0.05);
708 | }
709 |
710 | .setup-header {
711 | margin-bottom: 40px;
712 | text-align: center;
713 | }
714 |
715 | .setup-title {
716 | color: #00ff41;
717 | font-size: 0.7rem;
718 | font-weight: bold;
719 | text-shadow: 0 0 10px #00ff41, 0 0 20px #00ff41;
720 | letter-spacing: 1px;
721 | margin-bottom: 15px;
722 | font-family: 'Courier New', monospace;
723 | line-height: 1.0;
724 | }
725 |
726 | .setup-title pre {
727 | margin: 0;
728 | padding: 0;
729 | font-family: inherit;
730 | font-size: inherit;
731 | font-weight: inherit;
732 | text-shadow: inherit;
733 | letter-spacing: inherit;
734 | line-height: inherit;
735 | color: inherit;
736 | }
737 |
738 | .setup-subtitle {
739 | color: #a0ffa0;
740 | font-size: 1.1rem;
741 | text-align: center;
742 | margin-bottom: 0;
743 | font-weight: 400;
744 | line-height: 1.5;
745 | font-family: 'Courier New', monospace;
746 | }
747 |
748 | .setup-progress {
749 | margin-bottom: 40px;
750 | padding: 20px;
751 | background: rgba(0, 0, 0, 0.4);
752 | border-radius: 5px;
753 | border: 1px solid #00ff41;
754 | }
755 |
756 | .progress-bar {
757 | width: 100%;
758 | height: 12px;
759 | background: rgba(0, 0, 0, 0.5);
760 | border-radius: 3px;
761 | overflow: hidden;
762 | margin-bottom: 15px;
763 | border: 1px solid #00ff41;
764 | }
765 |
766 | .progress-fill {
767 | height: 100%;
768 | background: linear-gradient(90deg, #00ff41, #00cc33, #0099ff);
769 | background-size: 200% 100%;
770 | width: 0%;
771 | transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
772 | animation: shimmer 2s ease-in-out infinite;
773 | border-radius: 6px;
774 | box-shadow: 0 0 15px rgba(0, 255, 65, 0.4);
775 | }
776 |
777 | @keyframes shimmer {
778 | 0% { background-position: 200% 0; }
779 | 100% { background-position: -200% 0; }
780 | }
781 |
782 | .progress-text {
783 | color: #00ff41;
784 | font-size: 1rem;
785 | text-align: center;
786 | font-weight: 500;
787 | font-family: 'Courier New', monospace;
788 | }
789 |
790 | .setup-checklist {
791 | list-style: none;
792 | padding: 0;
793 | margin: 0 0 40px 0;
794 | }
795 |
796 | .checklist-item {
797 | display: flex;
798 | align-items: flex-start;
799 | padding: 20px;
800 | margin-bottom: 15px;
801 | background: rgba(0, 0, 0, 0.4);
802 | border-radius: 5px;
803 | border: 1px solid #00ff41;
804 | transition: all 0.3s ease;
805 | position: relative;
806 | overflow: hidden;
807 | }
808 |
809 | .checklist-item::before {
810 | content: '';
811 | position: absolute;
812 | top: 0;
813 | left: 0;
814 | width: 4px;
815 | height: 100%;
816 | background: rgba(0, 255, 65, 0.3);
817 | transition: all 0.3s ease;
818 | }
819 |
820 | .checklist-item:hover {
821 | background: rgba(0, 0, 0, 0.6);
822 | border-color: #00ff41;
823 | transform: translateY(-2px);
824 | box-shadow: 0 0 15px rgba(0, 255, 65, 0.3);
825 | }
826 |
827 | .checklist-item.completed::before {
828 | background: #00ff41;
829 | box-shadow: 0 0 10px rgba(0, 255, 65, 0.5);
830 | }
831 |
832 | .check-icon {
833 | font-size: 1.8rem;
834 | margin-right: 20px;
835 | min-width: 35px;
836 | text-align: center;
837 | opacity: 0.6;
838 | transition: all 0.4s ease;
839 | line-height: 1;
840 | color: #00ff41;
841 | }
842 |
843 | .check-icon.success {
844 | opacity: 1;
845 | color: #00ff41;
846 | text-shadow: 0 0 12px rgba(0, 255, 65, 0.6);
847 | animation: successPulse 0.6s ease-out;
848 | }
849 |
850 | .check-icon.error {
851 | opacity: 1;
852 | color: #ffff00;
853 | text-shadow: 0 0 12px rgba(255, 255, 0, 0.6);
854 | }
855 |
856 | .check-icon.pending {
857 | opacity: 1;
858 | color: #ffff00;
859 | animation: pendingPulse 1.8s ease-in-out infinite;
860 | }
861 |
862 | @keyframes successPulse {
863 | 0% { transform: scale(1); }
864 | 50% { transform: scale(1.2); }
865 | 100% { transform: scale(1); }
866 | }
867 |
868 | @keyframes pendingPulse {
869 | 0%, 100% {
870 | opacity: 0.6;
871 | transform: scale(1);
872 | }
873 | 50% {
874 | opacity: 1;
875 | transform: scale(1.1);
876 | }
877 | }
878 |
879 | .check-content {
880 | flex: 1;
881 | padding-top: 2px;
882 | }
883 |
884 | .check-title {
885 | color: #00ff41;
886 | font-size: 1.2rem;
887 | font-weight: 600;
888 | margin-bottom: 8px;
889 | line-height: 1.3;
890 | font-family: 'Courier New', monospace;
891 | }
892 |
893 | .check-description {
894 | color: #a0ffa0;
895 | font-size: 1rem;
896 | line-height: 1.5;
897 | margin-bottom: 0;
898 | font-family: 'Courier New', monospace;
899 | }
900 |
901 | .check-action {
902 | display: flex;
903 | align-items: flex-start;
904 | }
905 |
906 | .action-btn {
907 | background: #00ff41;
908 | color: #000000;
909 | border: 2px solid #00ff41;
910 | padding: 10px 20px;
911 | border-radius: 5px;
912 | font-weight: 600;
913 | font-size: 0.95rem;
914 | cursor: pointer;
915 | margin-left: 15px;
916 | margin-top: 5px;
917 | transition: all 0.3s ease;
918 | display: none;
919 | box-shadow: 0 0 10px rgba(0, 255, 65, 0.3);
920 | align-self: flex-start;
921 | font-family: 'Courier New', monospace;
922 | white-space: pre-line;
923 | text-align: center;
924 | line-height: 1.2;
925 | min-height: 45px;
926 | }
927 |
928 | .action-btn:hover:not(:disabled) {
929 | transform: translateY(-2px);
930 | box-shadow: 0 0 20px rgba(0, 255, 65, 0.5);
931 | background: #00cc33;
932 | color: #000000;
933 | }
934 |
935 | .action-btn:disabled {
936 | opacity: 0.6;
937 | cursor: not-allowed;
938 | transform: none;
939 | background: #666666;
940 | border-color: #666666;
941 | color: #333333;
942 | }
943 |
944 | .setup-actions {
945 | text-align: center;
946 | padding-top: 20px;
947 | }
948 |
949 | .lets-go-btn {
950 | background: #00ff41;
951 | color: #000000;
952 | border: 2px solid #00ff41;
953 | padding: 18px 50px;
954 | border-radius: 5px;
955 | font-size: 1.3rem;
956 | font-weight: 700;
957 | cursor: pointer;
958 | display: none;
959 | transition: all 0.3s ease;
960 | box-shadow: 0 0 20px rgba(0, 255, 65, 0.4);
961 | position: relative;
962 | overflow: hidden;
963 | font-family: 'Courier New', monospace;
964 | text-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
965 | }
966 |
967 | .lets-go-btn::before {
968 | content: '';
969 | position: absolute;
970 | top: 0;
971 | left: -100%;
972 | width: 100%;
973 | height: 100%;
974 | background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
975 | transition: left 0.5s;
976 | }
977 |
978 | .lets-go-btn:hover {
979 | transform: translateY(-3px);
980 | box-shadow: 0 0 30px rgba(0, 255, 65, 0.6);
981 | background: #00cc33;
982 | text-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
983 | }
984 |
985 | .lets-go-btn:hover::before {
986 | left: 100%;
987 | }
988 |
989 | .retry-btn {
990 | background: transparent;
991 | color: #666666;
992 | border: 2px solid #666666;
993 | padding: 15px 35px;
994 | border-radius: 5px;
995 | font-size: 1.1rem;
996 | font-weight: 500;
997 | cursor: pointer;
998 | display: none;
999 | margin-top: 20px;
1000 | transition: all 0.3s ease;
1001 | font-family: 'Courier New', monospace;
1002 | }
1003 |
1004 | .retry-btn:hover {
1005 | background: rgba(102, 102, 102, 0.1);
1006 | color: #888888;
1007 | border-color: #888888;
1008 | transform: translateY(-2px);
1009 | box-shadow: 0 0 10px rgba(102, 102, 102, 0.2);
1010 | }
1011 |
1012 | @media (max-width: 768px) {
1013 | .container {
1014 | grid-template-columns: 1fr;
1015 | grid-template-rows: auto auto 1fr auto;
1016 | height: 100vh;
1017 | }
1018 |
1019 | .sidecar {
1020 | order: 2;
1021 | max-height: 300px;
1022 | overflow: hidden;
1023 | }
1024 |
1025 | .main-content {
1026 | order: 3;
1027 | }
1028 |
1029 | .title {
1030 | font-size: 2rem;
1031 | letter-spacing: 4px;
1032 | }
1033 |
1034 | .title pre {
1035 | font-size: inherit;
1036 | letter-spacing: inherit;
1037 | }
1038 |
1039 | .input-area {
1040 | flex-direction: column;
1041 | gap: 10px;
1042 | }
1043 |
1044 | .model-select {
1045 | min-width: auto;
1046 | width: 100%;
1047 | }
1048 | }
1049 |
1050 | /* Context Menu Styles */
1051 | .context-menu {
1052 | position: fixed;
1053 | background: #1a1a2e;
1054 | border: 2px solid #00ff41;
1055 | border-radius: 8px;
1056 | padding: 8px 0;
1057 | box-shadow: 0 8px 25px rgba(0, 255, 65, 0.3);
1058 | z-index: 1000;
1059 | min-width: 200px;
1060 | font-family: 'Courier New', monospace;
1061 | display: none;
1062 | }
1063 |
1064 | .context-menu-item {
1065 | padding: 12px 16px;
1066 | cursor: pointer;
1067 | color: #00ff41;
1068 | border: none;
1069 | background: none;
1070 | width: 100%;
1071 | text-align: left;
1072 | font-family: inherit;
1073 | font-size: 0.9rem;
1074 | transition: background-color 0.2s ease;
1075 | display: flex;
1076 | align-items: center;
1077 | gap: 8px;
1078 | }
1079 |
1080 | .context-menu-item:hover {
1081 | background: rgba(0, 255, 65, 0.1);
1082 | color: #00ffff;
1083 | }
1084 |
1085 | .context-menu-item:focus {
1086 | outline: none;
1087 | background: rgba(0, 255, 65, 0.2);
1088 | }
1089 |
1090 | .context-menu-separator {
1091 | height: 1px;
1092 | background: #333;
1093 | margin: 4px 8px;
1094 | }
1095 |
1096 | .context-menu-item.builtin-info {
1097 | color: #ffd700;
1098 | cursor: default;
1099 | background: #2a2a2a;
1100 | }
1101 |
1102 | .context-menu-item.builtin-info:hover {
1103 | background: #2a2a2a;
1104 | color: #ffd700;
1105 | }
1106 |
1107 | /* Built-in Games Section for Model Download */
1108 | .builtin-games-section {
1109 | margin: 30px 0;
1110 | padding: 25px;
1111 | background: rgba(0, 255, 65, 0.05);
1112 | border: 2px solid #00ff41;
1113 | border-radius: 15px;
1114 | animation: gentle-glow 3s ease-in-out infinite alternate;
1115 | }
1116 |
1117 | @keyframes gentle-glow {
1118 | 0% { box-shadow: 0 0 15px rgba(0, 255, 65, 0.3); }
1119 | 100% { box-shadow: 0 0 25px rgba(0, 255, 65, 0.5); }
1120 | }
1121 |
1122 | .builtin-games-header {
1123 | text-align: center;
1124 | margin-bottom: 20px;
1125 | }
1126 |
1127 | .builtin-games-title {
1128 | font-size: 1.2rem;
1129 | font-weight: bold;
1130 | color: #00ffff;
1131 | text-shadow: 0 0 10px #00ffff;
1132 | margin-bottom: 8px;
1133 | }
1134 |
1135 | .builtin-games-subtitle {
1136 | font-size: 0.9rem;
1137 | color: #aaa;
1138 | font-style: italic;
1139 | }
1140 |
1141 | .builtin-games-grid {
1142 | display: grid;
1143 | grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
1144 | gap: 15px;
1145 | margin-top: 20px;
1146 | }
1147 |
1148 | .builtin-game-item {
1149 | background: rgba(0, 0, 0, 0.6);
1150 | border: 2px solid #00ff41;
1151 | border-radius: 10px;
1152 | padding: 20px;
1153 | cursor: pointer;
1154 | transition: all 0.3s ease;
1155 | text-align: center;
1156 | }
1157 |
1158 | .builtin-game-item:hover {
1159 | background: rgba(0, 255, 65, 0.1);
1160 | border-color: #00ffff;
1161 | transform: translateY(-2px);
1162 | box-shadow: 0 5px 15px rgba(0, 255, 65, 0.3);
1163 | }
1164 |
1165 | .builtin-game-icon {
1166 | font-size: 2rem;
1167 | margin-bottom: 10px;
1168 | }
1169 |
1170 | .builtin-game-title {
1171 | font-size: 1rem;
1172 | font-weight: bold;
1173 | color: #00ff41;
1174 | margin-bottom: 8px;
1175 | }
1176 |
1177 | .builtin-game-description {
1178 | font-size: 0.8rem;
1179 | color: #ccc;
1180 | line-height: 1.3;
1181 | }
1182 |
1183 | .no-builtin-games, .builtin-games-error {
1184 | text-align: center;
1185 | padding: 20px;
1186 | color: #666;
1187 | font-style: italic;
1188 | }
1189 |
1190 | .builtin-games-error {
1191 | color: #ff4444;
1192 | }
1193 |
1194 |
1195 |
--------------------------------------------------------------------------------