Instructions¶
This guide gets a typical Pygame project running in the browser. Start with the quick path below; if something breaks, use the troubleshooting links to jump to the relevant section.
Quick start¶
From your project root:
Then open http://localhost:8000 in your browser.
That is enough for many projects. By default, pygodide:
- looks for a
main()function inmain.py - reads dependencies from
requirements.txtand/orpyproject.toml - auto-converts simple synchronous game loops for the browser
Use a different port with pygodide serve . --port 3000, or build and serve
in one step:
Build details are written to build/pygodide-build.log.
Something went wrong?¶
| Symptom | What to try |
|---|---|
| Build fails or packages are missing | Declare dependencies |
| The wrong function runs, or nothing starts | Set the entry point |
| The page stays on "Loading..." or the game freezes | Make the game async-compatible |
ModuleNotFoundError in the browser |
Python path |
Asset FileNotFoundError in the browser |
Assets and paths |
| You are not sure what failed | Run a smoke test |
Check your build with a smoke test¶
pygodide smoke builds your app and opens it in a headless browser. Use it to
catch load/startup failures before you debug in a real browser.
Install for smoke tests (pip)¶
You need two things: the smoke extra (Playwright Python package) and a
Chromium browser binary for Playwright.
# 1. Install pygodide with Playwright support
pip install 'pygodide[smoke]'
# 2. Download the Chromium binary Playwright uses (once per machine/env)
playwright install chromium
Run a smoke test¶
From your game project root:
Recommended when something looks wrong:
By default the console only shows pass/fail. The full log is always written to
build/pygodide-smoke.log (dependency resolution, auto-async status, browser
errors).
Build only, without launching a browser (no Chromium needed):
Smoke suite (multiple fixtures)¶
With --suite, PATH is a directory of fixture apps each with a
testing_manifest.yaml (see the repo test_targets/ folder). Per-target
overrides come from those manifests. Single-app flags such as --app,
--dep, canvas options, --smoke-path, --timeout-ms, --post-ready-ms,
and --ready-log cannot be combined with --suite (pygodide errors instead
of ignoring them).
Make the game async-compatible¶
Pygame games need to yield to the browser event loop. Pygodide tries to do this
automatically during pygodide build by inserting await asyncio.sleep(1/120) into
simple while game loops in your entrypoint (or a helper it calls directly).
If the game never yields, hang guidance is painted before the entrypoint runs so you can still read fix steps on a frozen page.
Check whether auto-async worked:
Look for Auto async: transformed ... in the output or log. If you see
Auto async: skipped ..., convert the game manually using the steps below.
Disable auto-conversion with pygodide build . --no-auto-async or in
pyproject.toml:
Manual conversion¶
import asyncio- Change
def main():toasync def main(): - Add
await asyncio.sleep(1 / (fps * 2))once per frame inside the main loop (half a frame budget so work + yield can still hit your target FPS) - Keep local runs working with:
Pygodide imports your configured entry function in the browser, so the
if __name__ == "__main__": block is only for local runs.
Minimal example¶
import asyncio
import pygame
SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600
pygame.init()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
async def main():
clock = pygame.time.Clock()
fps = 60
running = True
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
screen.fill((0, 0, 0))
pygame.display.update()
clock.tick(fps)
# Half a frame budget: yields for paint without always undershooting FPS.
await asyncio.sleep(1 / (fps * 2))
if __name__ == "__main__":
asyncio.run(main())
Larger examples:
- ball bouncing: already-async game
- not async: sync loop that auto-asyncifies at build time
- numpy particles: custom entry point and extra dependencies
Configure your project¶
Entry point¶
If your game does not start at main() in main.py, tell pygodide which
function to run. The value uses module:callable format: a Python import path,
not a filename:
Or for a one-off build:
CLI --app overrides [tool.pygodide].app.
Dependencies¶
Pygodide installs your Python packages in the browser. It merges dependencies from these sources, in order (later entries override earlier ones for the same package name):
requirements.txt[project].dependenciesinpyproject.toml[tool.pygodide].dependencies- groups listed in
[tool.pygodide].dependency-groups - repeated
--depflags on the CLI
Declare each package once when you can. The build log and pygodide smoke .
--verbose show what was found and how each package will be installed:
pygame-ce→pyodide.loadPackage(...)- everything else →
micropip.install(...)
Without pyproject.toml: add a requirements.txt (see the
ball bouncing
example) and/or pass --dep on the command line.
Assets and paths¶
Load assets with paths relative to the project root, as you would locally:
The working directory is the project root, so those paths usually work as-is.
A browser FileNotFoundError means a wrong path or a file that was not packed
into app.zip. python-path does not affect asset loading; see
Python path for imports only.
See asset maze for nested assets loaded by plain relative paths.
What gets packaged into the build¶
pygodide build packs your project files into build/app.zip (one compressed
archive the browser downloads and unpacks). Shell files (index.html,
boot.js, favicon, logo) sit next to that zip. Only those packaged paths are
available in the browser (and in an itch ZIP).
- Without
include: auto-discovery packs most files. It skips tooling such as.git,.venv,build,__pycache__,pyproject.toml, and prior itch outputs named<project-folder>.ziporbuild.zip. - With
include: only matching paths are packed (an allowlist). Use this to leave out docs, tools, or other extras. The entry module must match or the build fails.
Include patterns¶
include uses
pathlib globs
relative to the project root (not regex, not basename-only matching).
| Pattern | Meaning |
|---|---|
main.py |
Exact file at the project root |
assets/* |
Files directly under assets/ (one level) |
assets/** |
All files under assets/ (any depth) |
**/*.png |
All .png files in the project |
** |
All files under the project root |
Patterns are rooted at the project (sprites/**, not /sprites/**). * is
one path segment; ** is any depth. Each pattern must match at least one
file. Tooling dirs and prior itch ZIPs are still skipped.
Saves and favicon¶
open() / pathlib work for the current page session (see
save slots).
A reload clears that virtual disk; durable saves are not provided yet.
A root favicon (favicon.svg, .png, .ico, and a few other names) is used
for the tab icon when present; otherwise pygodide ships a default.
Python path¶
Default python-path = ["."] is enough for most projects. It adds folders to
sys.path before importing your entry function. It does not control asset
file paths.
You usually do not need extra entries for asset folders, normal packages
(import game), or installed dependencies.
Add entries when you import loose modules from non-package folders:
If that needs PYTHONPATH=src:lib locally, mirror it:
Entries are relative to the project root; keep "." first when you use it.
Prefer real packages (from lib.helpers import ...) over growing
python-path. For a src/ layout where imports assume that root, add
"src".
Canvas size¶
These options set the HTML canvas size. They do not change Pygame's
set_mode(...) resolution; the surface is scaled to the canvas.
By default, pygodide scans packaged Python for set_mode((width, height))
(including simple constants like SCREEN_WIDTH), preferring the entry module.
Dummy surfaces such as set_mode((1, 1), pygame.NOFRAME) are ignored. Fallback:
800x600.
| Setting | What you see |
|---|---|
| Default | Fixed canvas at discovered set_mode size |
--canvas-width / --canvas-height |
Fixed pixel box |
--canvas-fit |
Largest size in the viewport that keeps aspect |
--canvas-width N --canvas-height M --canvas-fit |
Fit using N×M as the aspect |
--canvas-fill |
Fill the viewport (aspect may change) |
canvas-fit and canvas-fill cannot be combined.
Detecting the web runtime¶
For “am I in the browser WASM build?”:
True under pygodide; false under normal desktop CPython. Use a second check
only for Pyodide-specific APIs (js, pyodide.ffi, …):
See web_runtime for a smoke-tested example.
pyproject.toml reference¶
[project]
name = "my-game"
version = "0.1.0"
dependencies = [
"pygame-ce",
"numpy>=1.26",
]
[dependency-groups]
web = ["fastquadtree"]
[tool.pygodide]
app = "main:web_main"
auto-async = true
include = ["main.py", "sprites/**", "sounds/**"]
title = "My Game"
# canvas-fit = true
# canvas-width = 960
# canvas-height = 540
# canvas-fill = true
# python-path = [".", "src", "lib"]
dependencies = ["pyyaml"]
dependency-groups = ["web"]
| Field | Purpose |
|---|---|
app |
Entry module:callable. Default main:main. Entry point. |
auto-async |
Auto game-loop conversion. Default true. |
include |
Allowlist of files to pack into app.zip (path globs). Assets and paths. |
title |
HTML page title. Default: {project dir} - pygodide. |
canvas-width, canvas-height |
Fixed size, or aspect with canvas-fit. Canvas size. |
canvas-fit |
Scale to viewport, keep aspect. |
canvas-fill |
Stretch to fill viewport (aspect may change). |
python-path |
Extra sys.path roots. Default ["."]. Python path. |
dependencies |
Extra browser packages. Dependencies. |
dependency-groups |
Named groups from the pyproject to install. |
numpy particles
shows [project].dependencies plus app = "main:web_main".
Publishing to itch.io¶
Once your build works locally, you can package it for itch.io HTML uploads:
This builds your project and writes <project-name>.zip in the project
directory, with index.html at the archive root. Upload that ZIP as an HTML
game on itch.io. Use --zip-output path/to/game.zip to pick a different path.
Publishing to GitHub Pages¶
pygodide build . produces a static site in build/ (index.html, boot.js,
assets, and so on). GitHub Pages can host that
folder over HTTPS. Treat pygodide as a static site generator: commit your
source, build in CI, and publish only build/.
Local check first¶
Confirm the game works at http://localhost:8000. Prefer
keeping build/ out of git (generate it on each deploy).
Recommended: deploy with GitHub Actions¶
- In the GitHub repo: Settings → Pages → Build and deployment → Source: GitHub Actions.
- Add
.github/workflows/pages.yml(adjust Python version or build flags as needed):
name: Deploy game to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install pygodide
run: pip install pygodide
- name: Build web app
run: pygodide build . --clean
# optional fixed canvas size:
# run: pygodide build . --clean --canvas-width 1280 --canvas-height 720
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: build
- id: deployment
uses: actions/deploy-pages@v4
- Push to
main. The site will be athttps://<user-or-org>.github.io/<repo>/(or your custom domain if configured).
Every push rebuilds from source, so deploys stay reproducible.
Alternative: publish build/ without Actions¶
Build locally, then publish only the contents of build/ as the site root (for
example with gh-pages):
Or copy build/* onto a gh-pages branch manually. CI is usually easier to
keep up to date.
Still stuck?¶
If you've worked through the sections above and a real part of your game still will not build, load, or run in the browser, please open an issue using the My project didn't convert template.
That format keeps reports easy to act on. Please include:
- What happened?: what you ran, what you expected, and what you saw instead (browser behavior, error overlay text, and so on)
- Build log: the full contents of
build/pygodide-build.log(orbuild/pygodide-smoke.logif the failure came frompygodide smoke) - Link to your project (optional): a repo, gist, or zip so we can reproduce
- Anything else (optional): browser console errors (F12 → Console), screenshots, or a smaller repro case
Run pygodide smoke . --verbose before filing if you have not already; the
smoke log often captures browser-side failures that a plain build log does not.