Skip to main content

Lesson 26: Building Executables

  • Module 14: Ship It
  • Lesson 26 of 27
  • โฑ๏ธ About 1 h 45 min (instruction + lab)

You will turn a pygame-ce game into a program your friends can start with a double-click, with no Python on their computer. Getting a game out of your editor and onto other people's machines is the step that turns a project into something people actually play.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a frozen build contains and why it is tens of megabytes even for a small game.
  • Make a game freeze-ready: load assets relative to __file__ and write saves to the player's own data folder.
  • Build a one-folder, windowed app with PyInstaller 6 and read the .spec file it writes.
  • Debug a broken build with a console build, a crash log and a test from a different folder.
  • Compare PyInstaller, cx_Freeze, Nuitka and pygbag and pick one for a project.

Project: Star Catcher, a small game you make freeze-ready, build with PyInstaller and zip for players.

In This Lesson

๐Ÿ“ฆ What "Freezing" a Game Means

In Share Your Game you put a game on the web with pygbag and shared a source zip. The zip only works for people who already have Python and pygame-ce. A frozen build fixes that: a tool copies a Python interpreter, pygame-ce with its SDL libraries, your code and your assets into one folder, plus a small launcher program. The player double-clicks the launcher, and it runs your game with the Python it brought along.

graph LR A["star_catcher.py<br/>(your code)"] --> P["PyInstaller"] B["assets/<br/>(images, sounds)"] --> P C["Python + pygame-ce<br/>+ SDL libraries"] --> P P --> D["dist/StarCatcher/<br/>launcher + _internal/"] D --> E["StarCatcher-1.0.0-windows.zip<br/>(what players download)"]

Because the build carries a whole runtime, even a tiny game is big. On the author's Linux test machine (PyInstaller 6.22, pygame-ce 2.5.8, Python 3.10), the Star Catcher build folder was 33 MB and its zip was about 19.5 MB. A first test build of a minimal pygame-ce program came to 77 MB before numpy was excluded, because pygame-ce can use numpy when it is installed and PyInstaller collected it. Your numbers will be different on other systems; the lesson is to look at what went into your build.

๐Ÿ’ก Why this matters

Every extra step between "download" and "playing" loses players. "Install Python, then run pip, then run this command" loses most of them. A zip with a program inside, which you will make today, is what players expect, and it is what you will upload to itch.io in the next lesson.

๐Ÿงญ Make Your Game Freeze-Ready

Most broken builds are not PyInstaller's fault. They come from two habits that work fine while you run the game from your editor.

1. Load assets relative to the script, not the current folder

pygame.image.load("assets/star.png") looks in the current working directory, the folder the program was started from. Your editor usually starts it in the game's folder, so it works. A player who starts it from a shortcut, a terminal or a file manager may be somewhere else entirely. Try it with the lab's starter file from a different folder, and pygame-ce says exactly what went wrong:

FileNotFoundError: No file 'assets/star.png' found in working directory '/tmp'.

The fix is the pattern from the Intro course: build the path from __file__, the location of the script itself.

from pathlib import Path


def asset_path(name):
    """Find a bundled file next to this script, wherever it was started from."""
    return Path(__file__).resolve().parent / "assets" / name


star_image = pygame.image.load(asset_path("star.png")).convert_alpha()

That same line keeps working after freezing. PyInstaller 6's documentation recommends exactly this: when your game runs from a build, the launcher sets __file__ to a path inside the bundle's data folder, and --add-data "assets:assets" puts your assets right next to it. You may see older tutorials build paths from sys._MEIPASS, the bundle folder PyInstaller creates; with __file__ you don't need it. Save this as where.py and run it, then run it again from its build later, to see both cases:

import sys
from pathlib import Path

print("frozen:  ", getattr(sys, "frozen", False))
print("__file__:", Path(__file__).resolve())
print("assets:  ", Path(__file__).resolve().parent / "assets")

On the test machine, the frozen version printed frozen: True and a __file__ inside dist/where/_internal/, where the assets had been copied.

2. Write saves to the player's data folder

A frozen game's own folder is the wrong place for saves and settings. It may be read-only (Windows Program Files, a signed macOS app), it is replaced when the player installs an update, and a one-file build (section 3) runs from a temporary folder that is deleted when the game closes. Every operating system has a per-user folder for this instead:

import os
import sys
from pathlib import Path


def user_data_dir(app_name, platform=None):
    """The per-user folder where a game should write saves and settings."""
    platform = platform or sys.platform
    if platform == "win32":
        base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
    elif platform == "darwin":
        base = Path.home() / "Library" / "Application Support"
    else:                                               # Linux and friends
        base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
    return base / app_name


for system in ("win32", "darwin", "linux"):
    print(system, user_data_dir("StarCatcher", system))
SystemFolderTypical path
Windows%APPDATA%C:\Users\you\AppData\Roaming\StarCatcher
macOSApplication Support~/Library/Application Support/StarCatcher
Linux$XDG_DATA_HOME, or its default~/.local/share/StarCatcher

Combine that folder with the atomic save from Saving & Loading (write a temporary file, then os.replace() it onto the real one), and treat a missing or damaged file as "no save yet" instead of a crash:

def load_high_score(folder):
    """Return the saved high score, or 0 if there is none (or it is damaged)."""
    try:
        data = json.loads((folder / "highscore.json").read_text(encoding="utf-8"))
        return max(0, int(data["high_score"]))
    except (OSError, ValueError, KeyError, TypeError):
        return 0


def save_high_score(folder, score):
    """Write the high score atomically: a temp file first, then a rename."""
    folder.mkdir(parents=True, exist_ok=True)
    tmp = folder / "highscore.json.tmp"
    tmp.write_text(json.dumps({"high_score": score, "version": VERSION}), encoding="utf-8")
    os.replace(tmp, folder / "highscore.json")

If you would rather not maintain the platform table yourself, the third-party platformdirs package does the same job; the hand-written version above has no extra dependency.

โœ… Growth Mindset: "It Works on My Machine" Is a Starting Line

Every developer ships a build that works perfectly for them and crashes for the first friend who tries it. That is not a sign you did something foolish; it is how everyone learns what their machine was quietly doing for them (the right folder, an installed library, a font). Treat each "it crashed for them" report as a gift: it names an assumption you can now remove for good.

๐Ÿ› ๏ธ Your First Build with PyInstaller

Install PyInstaller into the same Python that runs your game (use py -m pip on Windows):

python3 -m pip install pyinstaller
python3 -m PyInstaller --version

Then, from the game's folder, build it. In your instructor's lab folder the script is called star_catcher_starter.py: save your finished copy as star_catcher.py first, or put the starter's name at the end of the command.

python3 -m PyInstaller --name StarCatcher --onedir --windowed \
    --exclude-module numpy --add-data "assets:assets" star_catcher.py
  • --name sets the program and folder name.
  • --onedir (the default) makes a folder: dist/StarCatcher/ holds the launcher, and _internal/ next to it holds Python, the libraries and your assets.
  • --windowed means no console window opens behind the game on Windows and macOS (and on macOS you get a StarCatcher.app).
  • --add-data "assets:assets" copies the assets folder into the build as assets. Since PyInstaller 6, the colon separator works on every system; older tutorials use a semicolon on Windows.
  • --exclude-module numpy leaves out a library this game never imports.

Run the result from a different folder to prove the paths work: dist/StarCatcher/StarCatcher on Linux, dist\StarCatcher\StarCatcher.exe on Windows, or open dist/StarCatcher.app on macOS. PyInstaller is not a cross-compiler: to make a Windows build you run it on Windows, and the same goes for macOS and Linux.

One folder (--onedir)One file (--onefile)
What you shipA folder (zip it)A single program file
Starting upRuns straight from the folderUnpacks itself to a temporary folder on every launch, then runs; that folder is deleted on exit
macOSThe supported way to make a .appWith --windowed, PyInstaller 6 prints a deprecation warning and says it will become an error in version 7
Size on the test machine33 MB folder, 19.5 MB zipped14 MB file for a minimal pygame-ce program

This course uses one-folder builds: they start without an unpacking step, they are the recommended layout for macOS, and zipping the folder gives players a single download anyway.

๐Ÿ“„ The Spec File

Each build also writes StarCatcher.spec, a Python file describing the build. Here is the one PyInstaller 6.22 wrote for the command above (with the script renamed star_catcher.py). Old tutorials show extra arguments such as a.zipped_data and a.zipfiles; PyInstaller 6 no longer uses them, so copy from a spec your own version generated rather than from the web.

# -*- mode: python ; coding: utf-8 -*-


a = Analysis(
    ['star_catcher.py'],
    pathex=[],
    binaries=[],
    datas=[('assets', 'assets')],
    hiddenimports=[],
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=['numpy'],
    noarchive=False,
    optimize=0,
)
pyz = PYZ(a.pure)

exe = EXE(
    pyz,
    a.scripts,
    [],
    exclude_binaries=True,
    name='StarCatcher',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    console=False,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
)
coll = COLLECT(
    exe,
    a.binaries,
    a.datas,
    strip=False,
    upx=True,
    upx_exclude=[],
    name='StarCatcher',
)

The spec is a recipe in three steps. Analysis finds everything your script needs, EXE builds the launcher, and COLLECT gathers it all into the dist folder. Once you have a spec, edit it and rebuild from it instead of retyping the options: python3 -m PyInstaller StarCatcher.spec. Commit it to version control with your game.

What hiddenimports is for

PyInstaller finds modules by reading your code's import statements, without running the game. It can't see a module whose name is only a string at run time:

import importlib

LEVEL_MODULES = ["levels.forest", "levels.cave"]      # names in strings: invisible to the analysis

for name in LEVEL_MODULES:
    level = importlib.import_module(name)

A build of that game would stop with a ModuleNotFoundError. List such modules in hiddenimports=['levels.forest', 'levels.cave'] (or pass --hidden-import levels.forest). You do not need to list pygame's own modules: pygame-ce ships a PyInstaller hook in its package (pygame/__pyinstaller/hook-pygame.py) that collects what it needs, so copied advice like hiddenimports=['pygame._sdl2', 'pkg_resources.py2_warn'] is unnecessary.

๐Ÿงช Test the Build Like a Player

Your computer has Python, pygame-ce and your files in all the usual places, so it hides problems. Test like someone who has none of that:

  1. Start it from somewhere else. Open a terminal in your home folder and run the program by its full path. This catches every current-folder path.
  2. Use a clean machine. A friend's computer, a second user account, or a virtual machine without Python.
  3. Build with a console while debugging. A --windowed build that crashes just disappears. Leave out --windowed (or set console=True in the spec) and the traceback appears in the console window.
  4. Keep a crash log. Players can't send you a traceback from a window that vanished, but they can send a file:
import traceback


def run_safely(main, log_folder):
    """Run main(); if it crashes, write the traceback where the player can find it."""
    try:
        main()
    except Exception:
        log_folder.mkdir(parents=True, exist_ok=True)
        (log_folder / "crash.log").write_text(traceback.format_exc(), encoding="utf-8")
        raise

Call it as run_safely(main, user_data_dir("StarCatcher")) in place of main(). It writes the log and then re-raises, so you still see the error while developing.

Symptom in the buildLikely causeFix
FileNotFoundError for an image or soundCurrent-folder path, or the asset was not added__file__-relative paths and --add-data
ModuleNotFoundErrorA module imported by a string nameAdd it to hiddenimports
Saves vanish or fail with "permission denied"Writing inside the game's own folderWrite to the per-user data folder
The window flashes and closesAny crash in a --windowed buildRebuild with a console, check the crash log
An antivirus or SmartScreen warningAn unsigned, little-known program; bundled launchers sometimes trigger false alarmsRebuild with the latest PyInstaller, report the false positive to the vendor, and consider code signing for a commercial release
macOS says the app "can't be opened"Gatekeeper blocks unsigned downloaded appsPlayers can allow it in System Settings under Privacy & Security; signing and notarizing with an Apple Developer account removes the warning

๐Ÿ“ค Package and Automate

Typing the same build command, zipping the folder and naming the zip by hand every time is how releases go wrong. The lab includes build_game.py, which does all of it: it calls PyInstaller from Python with PyInstaller.__main__.run(), then zips the build as StarCatcher-1.0.0-windows.zip (or -mac, -linux).

import shutil
import subprocess
import sys
from pathlib import Path

import PyInstaller.__main__

HERE = Path(__file__).resolve().parent
NAME = "StarCatcher"
VERSION = "1.0.0"


def main():
    script = HERE / (sys.argv[1] if len(sys.argv) > 1 else "star_catcher_starter.py")
    PyInstaller.__main__.run([
        str(script),
        "--name", NAME,
        "--onedir",                          # a folder: starts fast, and it is what macOS expects
        "--windowed",                        # no console window (Windows and macOS)
        "--noconfirm", "--clean",
        "--exclude-module", "numpy",         # the game never imports it; keeps the build smaller
        "--add-data", f"{HERE / 'assets'}:assets",
        "--distpath", str(HERE / "dist"),
        "--workpath", str(HERE / "build"),
        "--specpath", str(HERE),
    ])

    system = {"win32": "windows", "darwin": "mac"}.get(sys.platform, "linux")
    release = HERE / "release"
    release.mkdir(exist_ok=True)
    archive = release / f"{NAME}-{VERSION}-{system}.zip"
    if sys.platform == "darwin":
        # ditto keeps the links inside the .app bundle intact; a plain zip would not.
        app = HERE / "dist" / f"{NAME}.app"
        subprocess.run(["ditto", "-c", "-k", "--keepParent", str(app), str(archive)], check=True)
    else:
        shutil.make_archive(str(archive.with_suffix("")), "zip", HERE / "dist", NAME)
    print(f"Build: {HERE / 'dist' / NAME}")
    print(f"Zip for players: {archive}")


if __name__ == "__main__":
    main()

On macOS it zips the .app with the built-in ditto tool, because an app bundle contains links that a plain zip would turn into duplicate copies. Finder's Compress command does the same. A Windows installer (for example with Inno Setup) is optional: a zip the player extracts anywhere is enough for itch.io and for friends.

๐Ÿ”ฎ Predict, then run

Before you run build_game.py, predict: will the build include your highscore.json? (It won't. Saves live in the player's data folder, not in the game's folder, so every player starts with their own empty high score. That is exactly what you want.)

โš–๏ธ Other Tools

PyInstaller is the most widely used choice, but not the only one. They all solve the same problem in different ways:

ToolHow it worksOutputNotes
PyInstallerBundles your bytecode with a Python interpreter and a launcherFolder, single file or .appThis lesson's tool; pygame-ce ships a hook for it
cx_FreezeSimilar bundling, configured on the command line or in a setup.py/pyproject.tomlFolder; installer formats such as MSI on WindowsCurrent versions call the no-console Windows mode base="gui" (older tutorials say "Win32GUI")
NuitkaTranslates your Python to C and compiles it with a C compilerFolder (--standalone) or single file (--onefile)Slower builds and needs a C compiler; measure speed before assuming it is faster
pygbagRuns your game in the browser with a WebAssembly PythonA web page (itch.io can host it)From Share Your Game; needs the async main loop

One thing none of them do is hide your code. A frozen build contains your program as Python bytecode, and tools exist that extract and decompile it. Freezing is for convenience, not protection; never put secrets such as passwords or private keys in a game you ship.

๐Ÿ‹๏ธ Practice Exercise: Freeze Star Catcher

Objective: make Star Catcher run from any folder and save its high score in your data folder, then freeze it with PyInstaller and zip it for players.

Time: about 45 minutes. Starter file: star_catcher_starter.py, its assets folder and build_game.py (your instructor has them). The numbered comments in the starter match steps 2 to 4.

  1. Run the starter from its own folder: it works. Then run it from your home folder by its full path, and read the FileNotFoundError. (โ‰ˆ 5 min)
  2. Fix asset_path() so it builds the path from Path(__file__).resolve().parent. Run it from your home folder again. (โ‰ˆ 5 min)
  3. Write user_data_dir() for Windows, macOS and Linux. Print its result for your system. (โ‰ˆ 10 min)
  4. Write load_high_score() and save_high_score() with an atomic save. Play a 30-second round and check that the file appears in your data folder and the best score survives a restart. (โ‰ˆ 10 min)
  5. Install PyInstaller and run python build_game.py. Start the built game from a different folder and play a round. (โ‰ˆ 10 min)
  6. Open StarCatcher.spec and find your assets in datas. Unzip the zip from release/ somewhere new and run it from there. (โ‰ˆ 5 min)

You are done when:

  • the game runs from any folder, from source and from the build;
  • the build's bottom line says frozen build and the source run says running from source;
  • a new best score is saved in your data folder (not next to the game) and loads on the next launch;
  • closing the game prints a line like Score 14, best 21. Saves go to /home/you/.local/share/StarCatcher, and release/ holds a zip named with the version and your system.
๐Ÿ’ก Hint

Path(__file__).resolve().parent is the folder the script is in; add / "assets" / name. For the data folder, os.environ.get("APPDATA") returns None when the variable is missing, so os.environ.get("APPDATA") or fallback picks the fallback. If PyInstaller says "command not found", run it as python -m PyInstaller with the same Python you use for the game.

โœ… Example Solution

If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically; when you run it yourself they do nothing. The game needs assets/star.png next to it.

"""Star Catcher: Intermediate Lesson 26 practice exercise (solution).

A small game that is ready to freeze with PyInstaller: it finds its assets
relative to __file__, and it saves the high score in the player's own data
folder, never inside the game's folder. Left/Right move, Space replays.
"""
import json
import os
import random
import sys
from pathlib import Path

import pygame


APP_NAME = "StarCatcher"
VERSION = "1.0.0"
WIDTH, HEIGHT = 640, 480
ROUND_SECONDS = 30.0
BASKET_SPEED = 420                  # pixels per second
STAR_SPEED = 180                    # pixels per second
SPAWN_EVERY = 0.6                   # seconds


def asset_path(name):
    """Find a bundled file next to this script, wherever it was started from.

    From source, __file__ is this .py file. In a PyInstaller 6 build, the
    bootloader sets __file__ inside the bundle's data folder (sys._MEIPASS),
    where --add-data "assets:assets" put the assets, so the same line works.
    """
    return Path(__file__).resolve().parent / "assets" / name


def user_data_dir(app_name, platform=None):
    """The per-user folder where a game should write saves and settings."""
    platform = platform or sys.platform
    if platform == "win32":
        base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
    elif platform == "darwin":
        base = Path.home() / "Library" / "Application Support"
    else:                                               # Linux and friends
        base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
    return base / app_name


def load_high_score(folder):
    """Return the saved high score, or 0 if there is none (or it is damaged)."""
    try:
        data = json.loads((folder / "highscore.json").read_text(encoding="utf-8"))
        return max(0, int(data["high_score"]))
    except (OSError, ValueError, KeyError, TypeError):
        return 0


def save_high_score(folder, score):
    """Write the high score atomically: a temp file first, then a rename."""
    folder.mkdir(parents=True, exist_ok=True)
    tmp = folder / "highscore.json.tmp"
    tmp.write_text(json.dumps({"high_score": score, "version": VERSION}), encoding="utf-8")
    os.replace(tmp, folder / "highscore.json")


def is_frozen():
    return getattr(sys, "frozen", False)


def main():
    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption(f"Star Catcher {VERSION}")
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 30)
    star_image = pygame.image.load(asset_path("star.png")).convert_alpha()
    data_dir = user_data_dir(APP_NAME)
    high_score = load_high_score(data_dir)
    rng = random.Random()
    print(f"Star Catcher {VERSION} running {'frozen' if is_frozen() else 'from source'}")

    basket = pygame.FRect(0, 0, 90, 18)
    basket.midbottom = (WIDTH / 2, HEIGHT - 20)
    stars = []                          # FRects of falling stars
    spawn_timer = 0.0
    time_left = ROUND_SECONDS
    score = 0
    move = 0                            # -1 left, +1 right, from KEYDOWN/KEYUP
    running = True
    while running:
        dt = clock.tick(60) / 1000
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN and event.key in (pygame.K_LEFT, pygame.K_RIGHT):
                move = -1 if event.key == pygame.K_LEFT else 1
            elif event.type == pygame.KEYUP and event.key in (pygame.K_LEFT, pygame.K_RIGHT):
                move = 0
            elif event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE and time_left <= 0:
                stars.clear()
                score, time_left = 0, ROUND_SECONDS

        if time_left > 0:
            time_left -= dt
            basket.x += move * BASKET_SPEED * dt
            basket.clamp_ip(screen.get_rect())
            spawn_timer -= dt
            if spawn_timer <= 0:
                spawn_timer += SPAWN_EVERY
                stars.append(star_image.get_frect(midbottom=(rng.uniform(20, WIDTH - 20), 0)))
            for star in stars:
                star.y += STAR_SPEED * dt
            score += sum(1 for s in stars if s.colliderect(basket))
            stars = [s for s in stars if not s.colliderect(basket) and s.top < HEIGHT]
            if time_left <= 0 and score > high_score:
                high_score = score
                save_high_score(data_dir, high_score)   # the only place we write a file

        screen.fill((16, 20, 40))
        for star in stars:
            screen.blit(star_image, star)
        pygame.draw.rect(screen, (120, 200, 255), basket, border_radius=6)
        hud = f"Score {score}   Best {high_score}   Time {max(0, time_left):.0f}"
        screen.blit(font.render(hud, True, (240, 240, 240)), (12, 10))
        if time_left <= 0:
            done = font.render("Time! Press Space to play again", True, (255, 220, 120))
            screen.blit(done, done.get_rect(center=(WIDTH / 2, HEIGHT / 2)))
        mode = "frozen build" if is_frozen() else "running from source"
        screen.blit(font.render(f"v{VERSION}, {mode}", True, (120, 130, 160)), (12, HEIGHT - 34))
        pygame.display.flip()

    pygame.quit()
    print(f"Score {score}, best {high_score}. Saves go to {data_dir}")


if __name__ == "__main__":
    main()

๐Ÿ““ Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this lesson go for you?

โœ๏ธ This lesson's prompts:

  1. List everything your Tower Defense game reads or writes (images, fonts, saves). For each one, would it still work in a frozen build started from another folder?
  2. How big was your build, and what was the biggest thing in _internal/? Was anything there that your game does not use?
  3. Who is the first person you will send a build to, and what do you want to learn from watching them start it?

๐Ÿ“ Summary

A frozen build carries its own Python, libraries and assets, so players need nothing installed. Most build problems come from the game itself: paths relative to the current folder and saves written into the game's folder. You fixed both, with __file__-relative asset paths (which PyInstaller 6 supports directly) and a per-user data folder with atomic saves. Then you built a one-folder, windowed app, read its spec file, learned what hiddenimports is really for, tested the build the way a player would, and zipped it with a script so every release is made the same way.

๐ŸŽ“ Key Takeaways

  • Load assets with Path(__file__).resolve().parent / "assets"; it works from source and from a PyInstaller 6 build.
  • Write saves and settings to the player's data folder, never next to the game.
  • Prefer one-folder builds, zip the folder, and build on each system you ship to.
  • The spec file is your build recipe: keep it, edit it and rebuild from it.
  • hiddenimports is only for modules imported by name at run time; pygame-ce brings its own hook.
  • Test from another folder and on a clean machine; freezing is convenience, not code protection.

๐Ÿ”ญ Looking Ahead

You have a zip players can run. In the next lesson, Launch & Live Ops, you put it on itch.io, prepare a store page and press kit, plan your launch, and learn how to ship updates without breaking anyone's saves.

โ“ Common Questions

Can I build a Windows version on my Mac (or the other way around)?

Not with PyInstaller: it bundles the Python and libraries of the system it runs on. Build each version on its own system. A virtual machine, a friend's computer or a continuous-integration service with Windows, macOS and Linux runners are the usual answers.

How do I give my game an icon?

Add --icon with an .ico file on Windows or an .icns file on macOS (PyInstaller tries to convert other image formats if the Pillow package is installed). The window icon inside the game is separate: set it with pygame.display.set_icon().

Why did my build include numpy (or other libraries) I never use?

PyInstaller follows imports inside the libraries you use, too. pygame-ce can work with numpy, so if numpy is installed it may be collected. Exclude modules you know you don't use with --exclude-module, or build from a fresh virtual environment that contains only your game's requirements.

Do I still need the sys._MEIPASS trick I saw in a tutorial?

Not for files next to your script. In PyInstaller 6, __file__ already points inside the bundle, where --add-data put your assets. sys._MEIPASS still exists (it is the bundle's data folder), and some older code uses it; both find the same files.

My game runs from source but the build shows a black window and closes. What now?

Rebuild without --windowed and run it from a terminal, so you can read the traceback. Most often it is a missing asset or a path; the table in Test the Build Like a Player lists the usual causes.

๐ŸŽฏ Quick Quiz

Question 1: A game loads "assets/star.png". It works from the editor but fails with No file 'assets/star.png' found in working directory when started elsewhere. Why?

Question 2: Where should a frozen game write the player's high score?

Question 3: What is hiddenimports in a spec file for?

Question 4: Which statement about one-folder and one-file builds is true?

Question 5: Does freezing a game with PyInstaller protect your source code?

๐ŸŒŸ Going Further

  • Freeze your capstone: make Tower Defense freeze-ready (it already loads assets from __file__), add a saved best wave in the data folder, and build it with a copy of build_game.py.
  • Version everywhere: keep VERSION in one place and show it on the title screen, in the window caption and in the zip name, so bug reports tell you exactly which build a player has.
  • A settings file: save volume and a fullscreen flag in settings.json next to the high score, with defaults when the file is missing.
  • Read the docs: the PyInstaller manual, especially "Run-time Information" and "Using Spec Files", and the pygame-ce documentation.
  • Coming up in Game Dev III: Advanced: Profiling & Performance shows how to measure where your game spends its time before you reach for a different build tool.