Skip to main content

Building Executables

18 minute read

Converting Python Games to Standalone Executables

Package your Python game into standalone executables! Learn PyInstaller, cx_Freeze, Nuitka, create installers, handle dependencies, and deploy across platforms! 📦🚀💿

Understanding Executable Building

🎯 Why Build Executables?

Benefits of standalone executables:

graph TD A["Python Game"] --> B["Packaging Tool"] B --> C["PyInstaller"] B --> D["cx_Freeze"] B --> E["Nuitka"] B --> F["py2exe"] C --> G["Windows .exe"] C --> H["macOS .app"] C --> I["Linux binary"] G --> J["Installer"] H --> K["DMG"] I --> L["AppImage"] J --> M["Distribution"] K --> M L --> M

📦 Using PyInstaller


# Install PyInstaller
pip install pyinstaller

# Basic usage - creates a folder with executable
pyinstaller your_game.py

# Single file executable
pyinstaller --onefile your_game.py

# No console window (for GUI apps)
pyinstaller --onefile --windowed your_game.py

# With custom icon
pyinstaller --onefile --windowed --icon=game_icon.ico your_game.py

# Add data files
pyinstaller --onefile --add-data "assets;assets" your_game.py

# Linux/Mac syntax for data files
pyinstaller --onefile --add-data "assets:assets" your_game.py
        

PyInstaller Spec File

⚙️ Advanced Configuration


# game.spec - PyInstaller specification file
# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

# Analysis phase
a = Analysis(
    ['main.py'],
    pathex=[],
    binaries=[],
    datas=[
        ('assets', 'assets'),
        ('levels', 'levels'),
        ('sounds', 'sounds'),
        ('config.json', '.'),
    ],
    hiddenimports=[
        'pygame',
        'numpy',
        'PIL',
    ],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[
        'matplotlib',
        'tkinter',
        'test',
    ],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=block_cipher,
    noarchive=False,
)

# Remove unnecessary modules
a.binaries = [x for x in a.binaries if not x[0].startswith('api-ms-win')]

pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    [],
    name='MyAwesomeGame',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=False,
    disable_windowed_traceback=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
    version='version.txt',
    icon='icon.ico',
)

# macOS specific
app = BUNDLE(
    exe,
    name='MyAwesomeGame.app',
    icon='icon.icns',
    bundle_identifier='com.yourcompany.myawesomegame',
    info_plist={
        'NSHighResolutionCapable': 'True',
        'CFBundleShortVersionString': '1.0.0',
        'CFBundleVersion': '1.0.0',
    },
)
        

Handling Resources and Assets

📁 Resource Management


import os
import sys

def resource_path(relative_path):
    """Get absolute path to resource, works for dev and PyInstaller"""
    try:
        # PyInstaller creates a temp folder and stores path in _MEIPASS
        base_path = sys._MEIPASS
    except Exception:
        base_path = os.path.abspath(".")
    
    return os.path.join(base_path, relative_path)

# Usage in your game
class AssetLoader:
    def __init__(self):
        self.base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__)))
        
    def get_path(self, relative_path):
        return os.path.join(self.base_path, relative_path)
    
    def load_image(self, path):
        full_path = self.get_path(path)
        return pygame.image.load(full_path)
    
    def load_sound(self, path):
        full_path = self.get_path(path)
        return pygame.mixer.Sound(full_path)
    
    def load_font(self, path, size):
        full_path = self.get_path(path)
        return pygame.font.Font(full_path, size)

# Example usage
asset_loader = AssetLoader()
player_image = asset_loader.load_image('assets/sprites/player.png')
jump_sound = asset_loader.load_sound('assets/sounds/jump.wav')
game_font = asset_loader.load_font('assets/fonts/game.ttf', 24)

# Configuration file handling
import json

class ConfigManager:
    def __init__(self):
        # Use user's home directory for config in production
        if hasattr(sys, '_MEIPASS'):
            self.config_dir = os.path.expanduser('~/.mygame')
            if not os.path.exists(self.config_dir):
                os.makedirs(self.config_dir)
            self.config_path = os.path.join(self.config_dir, 'config.json')
        else:
            self.config_path = 'config.json'
    
    def load_config(self):
        if os.path.exists(self.config_path):
            with open(self.config_path, 'r') as f:
                return json.load(f)
        return self.get_default_config()
    
    def save_config(self, config):
        with open(self.config_path, 'w') as f:
            json.dump(config, f, indent=2)
    
    def get_default_config(self):
        return {
            'resolution': [1280, 720],
            'fullscreen': False,
            'volume': 0.7,
            'controls': {
                'up': pygame.K_w,
                'down': pygame.K_s,
                'left': pygame.K_a,
                'right': pygame.K_d,
            }
        }
        

cx_Freeze Alternative

🧊 Using cx_Freeze


# setup.py for cx_Freeze
import sys
from cx_Freeze import setup, Executable

# Dependencies
build_exe_options = {
    "packages": ["pygame", "numpy", "os"],
    "excludes": ["tkinter", "matplotlib"],
    "include_files": [
        ("assets", "assets"),
        ("levels", "levels"),
        ("sounds", "sounds"),
        ("icon.ico", "icon.ico"),
    ],
    "optimize": 2,
}

# Platform-specific options
base = None
if sys.platform == "win32":
    base = "Win32GUI"  # No console window

executables = [
    Executable(
        "main.py",
        base=base,
        target_name="MyGame",
        icon="icon.ico",
    )
]

setup(
    name="My Awesome Game",
    version="1.0.0",
    author="Your Name",
    description="An awesome game made with Python",
    options={"build_exe": build_exe_options},
    executables=executables,
)

# Build command:
# python setup.py build
# or for MSI installer on Windows:
# python setup.py bdist_msi
        

Nuitka for Performance

⚡ Compiling with Nuitka


# Install Nuitka
pip install nuitka

# Basic compilation
nuitka --standalone --onefile main.py

# With optimizations
nuitka --standalone --onefile --lto=yes --assume-yes-for-downloads main.py

# Windows specific with icon
nuitka --standalone --onefile --windows-disable-console \
       --windows-icon-from-ico=icon.ico main.py

# Include data files
nuitka --standalone --onefile --include-data-dir=assets=assets \
       --include-data-dir=levels=levels main.py

# Maximum optimization
nuitka --standalone --onefile --lto=yes --jobs=4 \
       --enable-plugin=numpy --show-progress main.py
        

Creating Installers

💿 Professional Installation

Windows - Inno Setup Script


; installer.iss - Inno Setup script
[Setup]
AppName=My Awesome Game
AppVersion=1.0.0
AppPublisher=Your Company
AppPublisherURL=https://yourwebsite.com
DefaultDirName={autopf}\MyAwesomeGame
DefaultGroupName=My Awesome Game
UninstallDisplayIcon={app}\MyGame.exe
Compression=lzma2
SolidCompression=yes
OutputDir=dist
OutputBaseFilename=MyAwesomeGame_Setup

[Files]
Source: "dist\MyGame.exe"; DestDir: "{app}"
Source: "dist\assets\*"; DestDir: "{app}\assets"; Flags: recursesubdirs
Source: "README.txt"; DestDir: "{app}"; Flags: isreadme

[Icons]
Name: "{group}\My Awesome Game"; Filename: "{app}\MyGame.exe"
Name: "{group}\Uninstall"; Filename: "{uninstallexe}"
Name: "{commondesktop}\My Awesome Game"; Filename: "{app}\MyGame.exe"

[Run]
Filename: "{app}\MyGame.exe"; Description: "Launch My Awesome Game"; Flags: postinstall nowait skipifsilent

[Registry]
Root: HKCU; Subkey: "Software\YourCompany\MyAwesomeGame"; ValueType: string; ValueName: "InstallPath"; ValueData: "{app}"
        

macOS - Creating DMG


# Create app bundle first with PyInstaller
pyinstaller --onefile --windowed --icon=icon.icns \
            --osx-bundle-identifier com.yourcompany.game main.py

# Create DMG using create-dmg
brew install create-dmg

create-dmg \
  --volname "My Awesome Game" \
  --volicon "icon.icns" \
  --background "installer_background.png" \
  --window-pos 200 120 \
  --window-size 600 400 \
  --icon-size 100 \
  --icon "MyGame.app" 200 190 \
  --hide-extension "MyGame.app" \
  --app-drop-link 400 190 \
  "MyGame.dmg" \
  "dist/"
        

Linux - AppImage


# Create AppImage
# First, create AppDir structure
mkdir -p MyGame.AppDir/usr/bin
mkdir -p MyGame.AppDir/usr/share/applications
mkdir -p MyGame.AppDir/usr/share/icons/hicolor/256x256/apps

# Copy executable and assets
cp dist/mygame MyGame.AppDir/usr/bin/
cp -r assets MyGame.AppDir/usr/bin/

# Create desktop entry
cat > MyGame.AppDir/mygame.desktop << 'EOF'
[Desktop Entry]
Type=Application
Name=My Awesome Game
Exec=mygame
Icon=mygame
Categories=Game;
X-AppImage-Version=1.0.0
EOF

# Copy icon
cp icon.png MyGame.AppDir/usr/share/icons/hicolor/256x256/apps/mygame.png

# Create AppRun script
cat > MyGame.AppDir/AppRun << 'EOF'
#!/bin/bash
HERE="$(dirname "$(readlink -f "${0}")")"
exec "${HERE}/usr/bin/mygame" "$@"
EOF
chmod +x MyGame.AppDir/AppRun

# Download appimagetool and create AppImage
wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage MyGame.AppDir MyGame-x86_64.AppImage
        

Build Automation

🔧 Automated Build Script


# build.py - Automated build script
import os
import sys
import shutil
import subprocess
import zipfile
from datetime import datetime

class GameBuilder:
    def __init__(self, game_name="MyGame", version="1.0.0"):
        self.game_name = game_name
        self.version = version
        self.build_dir = "build"
        self.dist_dir = "dist"
        self.release_dir = "releases"
        
    def clean(self):
        """Clean previous builds"""
        print("Cleaning previous builds...")
        for dir in [self.build_dir, self.dist_dir]:
            if os.path.exists(dir):
                shutil.rmtree(dir)
    
    def run_tests(self):
        """Run unit tests"""
        print("Running tests...")
        result = subprocess.run([sys.executable, "-m", "pytest", "tests/"],
                              capture_output=True, text=True)
        if result.returncode != 0:
            print("Tests failed!")
            return False
        print("All tests passed!")
        return True
    
    def build_executable(self):
        """Build executable with PyInstaller"""
        print(f"Building {self.game_name} v{self.version}...")
        
        cmd = [
            "pyinstaller",
            "--onefile",
            "--windowed",
            "--name", self.game_name,
            "--icon", "icon.ico",
            "--add-data", f"assets{os.pathsep}assets",
            "--add-data", f"sounds{os.pathsep}sounds",
            "--add-data", f"levels{os.pathsep}levels",
            "main.py"
        ]
        
        result = subprocess.run(cmd, capture_output=True, text=True)
        
        if result.returncode != 0:
            print("Build failed!")
            print(result.stderr)
            return False
        
        print("Build successful!")
        return True
    
    def create_release(self):
        """Create release package"""
        print("Creating release package...")
        
        if not os.path.exists(self.release_dir):
            os.makedirs(self.release_dir)
        
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        system = sys.platform
        
        if system == "win32":
            archive_name = f"{self.game_name}_v{self.version}_Windows_{timestamp}.zip"
        elif system == "darwin":
            archive_name = f"{self.game_name}_v{self.version}_macOS_{timestamp}.zip"
        else:
            archive_name = f"{self.game_name}_v{self.version}_Linux_{timestamp}.tar.gz"
        
        archive_path = os.path.join(self.release_dir, archive_name)
        
        # Create archive
        if system == "win32" or system == "darwin":
            with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zf:
                for root, dirs, files in os.walk(self.dist_dir):
                    for file in files:
                        file_path = os.path.join(root, file)
                        arc_name = os.path.relpath(file_path, self.dist_dir)
                        zf.write(file_path, arc_name)
        else:
            # Linux - use tar.gz
            import tarfile
            with tarfile.open(archive_path, 'w:gz') as tar:
                tar.add(self.dist_dir, arcname=self.game_name)
        
        print(f"Release created: {archive_path}")
        return True
    
    def build_all(self):
        """Complete build process"""
        print(f"Building {self.game_name} v{self.version}")
        print("=" * 50)
        
        self.clean()
        
        if not self.run_tests():
            print("Build aborted due to test failures")
            return False
        
        if not self.build_executable():
            print("Build aborted due to compilation errors")
            return False
        
        if not self.create_release():
            print("Warning: Release creation failed")
        
        print("\nBuild complete!")
        return True

# Usage
if __name__ == "__main__":
    builder = GameBuilder("MyAwesomeGame", "1.0.0")
    builder.build_all()
        

Common Issues and Solutions

⚠️ Troubleshooting

Missing Modules


# Add hidden imports in spec file
hiddenimports=['pkg_resources.py2_warn', 'pygame._sdl2']

# Or use command line
pyinstaller --hidden-import=pygame._sdl2 main.py
        

Antivirus False Positives

Large File Size

Path Issues


# Always use resource_path function
def resource_path(relative_path):
    base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__)))
    return os.path.join(base_path, relative_path)

# Never use hardcoded paths
# BAD: image = pygame.image.load("C:/game/assets/player.png")
# GOOD: image = pygame.image.load(resource_path("assets/player.png"))
        

Version Information

📋 Adding Version Info (Windows)


# version.txt file for PyInstaller
VSVersionInfo(
  ffi=FixedFileInfo(
    filevers=(1, 0, 0, 0),
    prodvers=(1, 0, 0, 0),
    mask=0x3f,
    flags=0x0,
    OS=0x40004,
    fileType=0x1,
    subtype=0x0,
    date=(0, 0)
  ),
  kids=[
    StringFileInfo(
      [
      StringTable(
        u'040904B0',
        [StringStruct(u'CompanyName', u'Your Company'),
        StringStruct(u'FileDescription', u'My Awesome Game'),
        StringStruct(u'FileVersion', u'1.0.0.0'),
        StringStruct(u'InternalName', u'mygame'),
        StringStruct(u'LegalCopyright', u'Copyright (c) 2024 Your Company'),
        StringStruct(u'OriginalFilename', u'MyGame.exe'),
        StringStruct(u'ProductName', u'My Awesome Game'),
        StringStruct(u'ProductVersion', u'1.0.0.0')])
      ]), 
    VarFileInfo([VarStruct(u'Translation', [1033, 1200])])
  ]
)
        

Best Practices

✨ Executable Building Best Practices

Key Takeaways

🏋️‍♂️ Practice Exercise

🏋️‍♂️ Exercise 1: Three Strategies, One Asset — sys._MEIPASS Resolution + .spec File as Build Schema + Hidden Imports Bridge in One Pygame Window

Objective: Build a runnable pygame window in roughly 95 lines that shows three orthogonal publishing-domain disciplines visible per frame on a 1088×480 window: a 768×480 demo area split into three vertical columns (one per resource-path resolution strategy) plus a 280px right-side panel showing the auto-synthesized .spec file. The demo simulates dev-vs-frozen mode via key F (a global FROZEN_SIM bool) since we cannot literally freeze the demo at runtime. Three strategies attempt to resolve the same asset player.png against the current simulated mode: Strategy A uses a hardcoded absolute path C:\game\assets\player.png that breaks the moment the user moves the .exe; Strategy B uses os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'player.png') which succeeds in dev but fails in frozen mode because __file__ points into the bootstrap loader rather than the extracted MEIPASS directory; Strategy C uses getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) plus os.path.join(base, 'assets', 'player.png') which is the canonical PyInstaller idiom that works in BOTH modes because the conditional logic is folded into the getattr lookup itself rather than a separate if frozen: branch. (a) sys._MEIPASS detection and dev-vs-frozen path-resolution duality: each strategy's column shows its resolved path string and an OK/FAIL status; toggling F flips the columns visibly — Strategy A stays FAIL in both modes (broken whenever the literal path doesn't match runtime location); Strategy B flips OK→FAIL (works in dev, breaks in frozen); Strategy C stays OK→OK (the only universal solution). (b) .spec file as build-configuration data externalization at BUILD-CONFIG scope: the right-side panel renders a synthesized .spec file from three module-level lists DECLARED_ASSETS / HIDDEN_IMPORTS / EXCLUDES, formatting Analysis(datas=[...], hiddenimports=[...], excludes=[...]) plus EXE(name='MyGame', console=False, ...) — the .spec file IS data the game declares about itself for the build pipeline, externalized from the game's runtime source code. Same data-driven externalization shape as architecture_save_load schema, pathfinding terrain-cost dict, behavior-trees child-list-order, and decision-making personality dict, now applied at BUILD-CONFIG scope. (c) Hidden imports as static-analysis-vs-runtime-discovery asymmetry: keys 1/2/3 simulate three import-discovery scenarios — 1 leaves the baseline unchanged (a plain import pygame statically detected by AST parse, no hidden_imports needed); 2 appends 'importlib_plugin' to HIDDEN_IMPORTS (simulating a dynamic importlib.import_module(plugin_name) call invisible to PyInstaller's static AST parser); 3 appends 'pygame._sdl2_extra' (simulating a library-internal lazy load). Pressing 2 or 3 visibly grows HIDDEN_IMPORTS and the .spec panel updates live, demonstrating that hidden_imports is the explicit declaration that bridges what static analysis missed — same design-time-validation-vs-runtime-discovery pattern as platformer_level_design's validate() (catches design-time errors before shipping; cost: one .spec file edit) vs is_solid() (catches runtime errors; cost: a shipped game crashing with ModuleNotFoundError when the user opens it) applied at build-time-vs-ship-time scope. R resets HIDDEN_IMPORTS to the baseline. HUD shows current mode (DEV / FROZEN), simulated sys._MEIPASS value (None in dev, /tmp/_MEIxyz12 in frozen), per-strategy resolved paths, and key bindings — three orthogonal publishing-domain disciplines visible per frame as concrete strings and OK/FAIL status flags. OPENS the publishing module 0/5 → 1/5 partial; module-completeness stays 11/13 since publishing doesn't close in one chat (4 publishing lessons remain after this one: marketing / performance / platforms / updates).

Instructions:

  1. Define three module-level lists: DECLARED_ASSETS = ['player.png', 'jump.wav', 'tilemap.png'], HIDDEN_IMPORTS = ['pygame._sdl2'], EXCLUDES = ['matplotlib', 'tkinter']. Define a global FROZEN_SIM = False that key F toggles. Define SIMULATED_MEIPASS = '/tmp/_MEIxyz12' as the path that simulated frozen mode reports (a stand-in for the real PyInstaller TEMP directory).
  2. Implement three resource-path resolution functions: strategy_a(asset, frozen) returns the hardcoded path 'C:\\game\\assets\\' + asset; strategy_b(asset, frozen) returns None if frozen else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', asset); strategy_c(asset, frozen) returns os.path.join(SIMULATED_MEIPASS if frozen else os.path.dirname(os.path.abspath(__file__)), 'assets', asset).
  3. Implement check_path(p) that returns True if p is non-None, contains 'assets', and does not start with the hardcoded 'C:\\game' prefix; otherwise False. Implement render_spec() that returns a multi-line string formatting Analysis(datas=[...], hiddenimports=[...], excludes=[...]) plus EXE(name='MyGame', console=False).
  4. Initialize pygame at 1088×480. Lay out three columns at x=24, x=280, x=536 (each ~240px wide) plus a .spec panel at x=792 (~280px wide). Use Consolas/Courier monospace fonts at sizes 14 and 18.
  5. Per-frame: draw column headers ('A: Hardcoded' / 'B: __file__-only' / 'C: _MEIPASS-aware'); under each header render the resolved path string (wrapped) and an OK/FAIL status colored green/red; render the .spec panel with the synthesized content; render the HUD at the bottom showing mode, simulated sys._MEIPASS value, and key bindings.
  6. Handle keys: F toggles FROZEN_SIM; 2 appends 'importlib_plugin' to HIDDEN_IMPORTS if not already present; 3 appends 'pygame._sdl2_extra' if not already present; R resets HIDDEN_IMPORTS to ['pygame._sdl2']. The .spec panel re-renders every frame from the current lists, so additions appear live.
  7. Verify the three orthogonal axes are visible per frame: (a) toggling F flips Strategy B's status from OK to FAIL while Strategy C's status stays OK — same input asset, three strategies, different success/failure across the dev-vs-frozen flip; (b) the .spec panel updates live as HIDDEN_IMPORTS grows when keys 2/3 fire — the .spec file IS data; (c) the strategy columns each render a different resolved-path STRING based on the same input asset name — same input, three strategies, three outputs, only one universally correct.
💡 Hint

The whole point of the demo is that one code path works in both dev mode AND frozen mode via the canonical getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) idiom. Strategy C succeeds where A and B fail because the conditional logic (which base path to use) is folded into the getattr lookup itself rather than a separate if frozen: branch — same runtime polymorphism via attribute lookup pattern as Python's general “look up the right thing in the right namespace” idiom. For the .spec panel, a simple multi-line f-string formatting Analysis(datas=[...], hiddenimports=[...], ...) is enough — you don't need to actually run PyInstaller; the visual point is that the .spec file IS data the game declares about itself for the build pipeline. For the hidden-imports demo, the static-analysis vs runtime-discovery distinction is the key insight — the list grows with each dynamic-import simulation key (2 or 3) because PyInstaller wouldn't have detected those imports without the explicit declaration.

✅ Example Solution
import os
import sys
import pygame

W, H = 1088, 480
FPS = 60

DECLARED_ASSETS = ['player.png', 'jump.wav', 'tilemap.png']
HIDDEN_IMPORTS = ['pygame._sdl2']
EXCLUDES = ['matplotlib', 'tkinter']
SIMULATED_MEIPASS = '/tmp/_MEIxyz12'

FROZEN_SIM = False

def strategy_a(asset, frozen):
    return 'C:\\game\\assets\\' + asset  # hardcoded; broken if user moves exe

def strategy_b(asset, frozen):
    if frozen:
        return None  # __file__ points to bootstrap, not MEIPASS
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', asset)

def strategy_c(asset, frozen):
    base = SIMULATED_MEIPASS if frozen else os.path.dirname(os.path.abspath(__file__))
    return os.path.join(base, 'assets', asset)

def check_path(p):
    return p is not None and 'assets' in p and not p.startswith('C:\\game')

def render_spec():
    datas = ', '.join(f"('{a}', 'assets')" for a in DECLARED_ASSETS)
    hi = ', '.join(f"'{i}'" for i in HIDDEN_IMPORTS)
    ex = ', '.join(f"'{e}'" for e in EXCLUDES)
    return (f"# game.spec\n"
            f"a = Analysis(\n"
            f"  ['main.py'],\n"
            f"  datas=[{datas}],\n"
            f"  hiddenimports=[{hi}],\n"
            f"  excludes=[{ex}],\n"
            f")\n"
            f"exe = EXE(pyz, a.scripts,\n"
            f"  name='MyGame', console=False,\n"
            f")")

pygame.init()
screen = pygame.display.set_mode((W, H))
clock = pygame.time.Clock()
small = pygame.font.SysFont('Consolas', 14)
big = pygame.font.SysFont('Consolas', 18, bold=True)

COLS = [(24, 'A: Hardcoded', strategy_a, (255, 110, 110)),
        (280, 'B: __file__-only', strategy_b, (255, 200, 80)),
        (536, 'C: _MEIPASS-aware', strategy_c, (110, 220, 140))]

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_f:
                FROZEN_SIM = not FROZEN_SIM
            elif event.key == pygame.K_2 and 'importlib_plugin' not in HIDDEN_IMPORTS:
                HIDDEN_IMPORTS.append('importlib_plugin')
            elif event.key == pygame.K_3 and 'pygame._sdl2_extra' not in HIDDEN_IMPORTS:
                HIDDEN_IMPORTS.append('pygame._sdl2_extra')
            elif event.key == pygame.K_r:
                HIDDEN_IMPORTS = ['pygame._sdl2']

    screen.fill((20, 20, 28))

    for col_x, label, fn, color in COLS:
        screen.blit(big.render(label, True, color), (col_x, 20))
        path = fn('player.png', FROZEN_SIM)
        ok = check_path(path)
        status = 'OK' if ok else 'FAIL'
        sc = (110, 220, 140) if ok else (255, 110, 110)
        screen.blit(big.render(status, True, sc), (col_x, 60))
        path_str = str(path) if path else '(None)'
        for i in range(0, len(path_str), 24):
            screen.blit(small.render(path_str[i:i+24], True, (200, 200, 210)),
                        (col_x, 100 + (i // 24) * 18))

    pygame.draw.rect(screen, (40, 40, 50), (792, 10, 280, 460), border_radius=4)
    spec_text = render_spec()
    for i, line in enumerate(spec_text.split('\n')):
        screen.blit(small.render(line, True, (180, 200, 220)), (800, 24 + i * 18))

    mode = 'FROZEN' if FROZEN_SIM else 'DEV'
    meipass = SIMULATED_MEIPASS if FROZEN_SIM else 'None'
    hud = f"Mode: {mode}  |  sys._MEIPASS: {meipass}  |  F=toggle 2/3=add hidden R=reset"
    screen.blit(small.render(hud, True, (200, 200, 220)), (24, 450))

    pygame.display.flip()
    clock.tick(FPS)

pygame.quit()

🎯 Quick Quiz

Question 1: Why is the canonical PyInstaller resource-path idiom getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))) rather than just hardcoding an absolute path or using __file__ directly?

Question 2: What is the architectural significance of PyInstaller's .spec file containing Analysis(datas=[...], hiddenimports=[...], excludes=[...]) as a Python data-construction call rather than as command-line flags or hardcoded inside PyInstaller's source?

Question 3: Why does the .spec file's hiddenimports=['pygame._sdl2', 'pkg_resources.py2_warn'] list exist as an explicit declaration when PyInstaller's Analysis phase already walks the import graph?

What's Next?

With your game packaged, let's explore distribution platforms to reach your audience!