Lesson 26: Building Executables
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
.specfile 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.
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))
| System | Folder | Typical path |
|---|---|---|
| Windows | %APPDATA% | C:\Users\you\AppData\Roaming\StarCatcher |
| macOS | Application 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
--namesets 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.--windowedmeans no console window opens behind the game on Windows and macOS (and on macOS you get aStarCatcher.app).--add-data "assets:assets"copies theassetsfolder into the build asassets. Since PyInstaller 6, the colon separator works on every system; older tutorials use a semicolon on Windows.--exclude-module numpyleaves 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 ship | A folder (zip it) | A single program file |
| Starting up | Runs straight from the folder | Unpacks itself to a temporary folder on every launch, then runs; that folder is deleted on exit |
| macOS | The supported way to make a .app | With --windowed, PyInstaller 6 prints a deprecation warning and says it will become an error in version 7 |
| Size on the test machine | 33 MB folder, 19.5 MB zipped | 14 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:
- 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.
- Use a clean machine. A friend's computer, a second user account, or a virtual machine without Python.
- Build with a console while debugging. A
--windowedbuild that crashes just disappears. Leave out--windowed(or setconsole=Truein the spec) and the traceback appears in the console window. - 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 build | Likely cause | Fix |
|---|---|---|
FileNotFoundError for an image or sound | Current-folder path, or the asset was not added | __file__-relative paths and --add-data |
ModuleNotFoundError | A module imported by a string name | Add it to hiddenimports |
| Saves vanish or fail with "permission denied" | Writing inside the game's own folder | Write to the per-user data folder |
| The window flashes and closes | Any crash in a --windowed build | Rebuild with a console, check the crash log |
| An antivirus or SmartScreen warning | An unsigned, little-known program; bundled launchers sometimes trigger false alarms | Rebuild 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 apps | Players 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:
| Tool | How it works | Output | Notes |
|---|---|---|---|
| PyInstaller | Bundles your bytecode with a Python interpreter and a launcher | Folder, single file or .app | This lesson's tool; pygame-ce ships a hook for it |
| cx_Freeze | Similar bundling, configured on the command line or in a setup.py/pyproject.toml | Folder; installer formats such as MSI on Windows | Current versions call the no-console Windows mode base="gui" (older tutorials say "Win32GUI") |
| Nuitka | Translates your Python to C and compiles it with a C compiler | Folder (--standalone) or single file (--onefile) | Slower builds and needs a C compiler; measure speed before assuming it is faster |
| pygbag | Runs your game in the browser with a WebAssembly Python | A 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.
- 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) - Fix
asset_path()so it builds the path fromPath(__file__).resolve().parent. Run it from your home folder again. (โ 5 min) - Write
user_data_dir()for Windows, macOS and Linux. Print its result for your system. (โ 10 min) - Write
load_high_score()andsave_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) - Install PyInstaller and run
python build_game.py. Start the built game from a different folder and play a round. (โ 10 min) - Open
StarCatcher.specand find your assets indatas. Unzip the zip fromrelease/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 buildand the source run saysrunning 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, andrelease/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:
- 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?
- How big was your build, and what was the biggest thing in
_internal/? Was anything there that your game does not use? - 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.
hiddenimportsis 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 ofbuild_game.py. - Version everywhere: keep
VERSIONin 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.jsonnext 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.