From dcabbd0f51ba7dfe4c4c99706210fe27e299a0be Mon Sep 17 00:00:00 2001 From: Stefan Schroeder Date: Mon, 7 Sep 2026 21:27:59 -0500 Subject: [PATCH 1/2] Trim the web build and serve it the way a host would Everything here is small next to the ~10MB CPython runtime the page pulls from the pygbag CDN, but it removes files nothing reads and the debug chrome that does not belong in an embed on a portfolio page. Output goes from 77KB to 34KB. - Drop the .apk. pygbag emits both an .apk and a .tar.gz holding the same files, and the loader only reads the .apk when the page is hosted on itch.io. Every other host takes the .tar.gz. - Drop pygbag's favicon and its link tag, which mean nothing inside an iframe, and the browserfs.min.js script tag, which pygbag emits with a doubled slash so the CDN 404s it on every load. - Turn off the xterm.js debug console and the audio shim via data-os. The console is ~90KB over the wire and this game has no sound. Python output still reaches the browser console. - Make the page transparent behind the canvas. pygbag paints it flat grey, once in CSS and again from JS after boot, which would sit as a grey box inside a themed page. Also stop passing --serve through to pygbag. Its test server rewrites the CDN URLs to proxy through itself, so what you were testing was not what a real host serves. The build now always produces the trimmed output and --serve hands it to a plain static server afterwards. Co-Authored-By: Claude Opus 5 --- build_web.py | 116 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/build_web.py b/build_web.py index 372c409..3c2263c 100644 --- a/build_web.py +++ b/build_web.py @@ -16,7 +16,10 @@ """ import argparse +import functools +import http.server import shutil +import socketserver import subprocess import sys import tempfile @@ -25,11 +28,60 @@ PROJECT_DIR = Path(__file__).resolve().parent OUTPUT_DIR = PROJECT_DIR / "build" / "web" +# pygbag names the bundle after the folder it builds from. +APP_NAME = "flappy-bird" + # Everything the game opens at runtime, and nothing else. RUNTIME_FILES = ["main.py", "flappybirdclone.py"] RUNTIME_DIRS = ["images"] +def trim(output_dir, app_name): + """Strip what the embedded build never uses. + + None of this is large next to the ~10MB CPython runtime the page + pulls from the pygbag CDN, but it removes a dead file, a request + that 404s, and the debug terminal, which does not belong in an + embed on a portfolio page. + """ + # The .apk is only read when the page is hosted on itch.io; every + # other host takes the .tar.gz. See the loader in index.html. + apk = output_dir / f"{app_name}.apk" + if apk.exists(): + apk.unlink() + + # pygbag's own favicon, irrelevant inside an iframe. + favicon = output_dir / "favicon.png" + if favicon.exists(): + favicon.unlink() + + index = output_dir / "index.html" + html = index.read_text(encoding="utf-8") + replacements = [ + # vtx is the xterm.js debug console (~90KB over the wire) and + # snd is the audio shim. This game has neither a console worth + # showing nor any sound. + ('data-os="vtx,snd,gui"', 'data-os="gui"'), + ('', ""), + # pygbag emits this with a doubled slash and the CDN 404s it. + ('', ""), + # pygbag paints the page around the canvas a flat grey, once in + # CSS and again from JS after boot. Both have to go transparent + # so an iframe picks up whatever the host page is using and the + # embed works in light and dark themes alike. + ("background-color:powderblue;", "background-color: transparent;"), + ('platform.document.body.style.background = "#7f7f7f"', + 'platform.document.body.style.background = "transparent"'), + ] + for old, new in replacements: + if old not in html: + print(f" warning: could not trim, pattern not found: {old[:60]}") + html = html.replace(old, new) + index.write_text(html, encoding="utf-8") + + def stage(staging_dir): """Copy the runtime files into a clean folder for pygbag to pack.""" for name in RUNTIME_FILES: @@ -38,12 +90,12 @@ def stage(staging_dir): shutil.copytree(PROJECT_DIR / name, staging_dir / name) -def build(staging_dir, serve): +def build(staging_dir): """Run pygbag over the staged folder.""" - command = [sys.executable, "-m", "pygbag", "--title", "Flappy Bird"] - if not serve: - command.append("--build") - command.append(str(staging_dir)) + command = [ + sys.executable, "-m", "pygbag", "--build", + "--title", "Flappy Bird", str(staging_dir), + ] subprocess.run(command, check=True) @@ -62,6 +114,22 @@ def collect(staging_dir): shutil.copytree(built, OUTPUT_DIR, dirs_exist_ok=True) +def serve(port): + """Serve the trimmed build, the way a real host would. + + pygbag's own test server rewrites the CDN URLs to proxy through + itself. Serving the files as they are keeps the page pointed at the + real CDN, which is what it will do once it is deployed. + """ + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, directory=str(OUTPUT_DIR) + ) + with socketserver.TCPServer(("127.0.0.1", port), handler) as httpd: + print(f"\nServing {OUTPUT_DIR} on http://localhost:{port}/") + print("Ctrl+C to stop.") + httpd.serve_forever() + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -69,28 +137,34 @@ def main(): action="store_true", help="serve the build on http://localhost:8000 when it is done", ) + parser.add_argument( + "--port", type=int, default=8000, help="port for --serve" + ) args = parser.parse_args() with tempfile.TemporaryDirectory() as tmp: # pygbag names the bundle after this folder. - staging_dir = Path(tmp) / "flappy-bird" + staging_dir = Path(tmp) / APP_NAME staging_dir.mkdir() stage(staging_dir) - build(staging_dir, args.serve) - if not args.serve: - collect(staging_dir) - - if not args.serve: - total = sum( - path.stat().st_size - for path in OUTPUT_DIR.rglob("*") - if path.is_file() - ) - print(f"\nBuilt {OUTPUT_DIR} ({total / 1024:.0f} KB)") - for path in sorted(OUTPUT_DIR.rglob("*")): - if path.is_file(): - size = path.stat().st_size / 1024 - print(f" {path.relative_to(OUTPUT_DIR)} {size:.0f} KB") + build(staging_dir) + collect(staging_dir) + + trim(OUTPUT_DIR, APP_NAME) + + total = sum( + path.stat().st_size + for path in OUTPUT_DIR.rglob("*") + if path.is_file() + ) + print(f"\nBuilt {OUTPUT_DIR} ({total / 1024:.0f} KB)") + for path in sorted(OUTPUT_DIR.rglob("*")): + if path.is_file(): + size = path.stat().st_size / 1024 + print(f" {path.relative_to(OUTPUT_DIR)} {size:.0f} KB") + + if args.serve: + serve(args.port) if __name__ == "__main__": From ea165e5940c51de586c8cad506c0177710383355 Mon Sep 17 00:00:00 2001 From: Stefan Schroeder Date: Mon, 7 Sep 2026 22:04:07 -0500 Subject: [PATCH 2/2] Add CI and a publish pipeline to the website Lint, tests and the web build run on every pull request. On a push to main the built bundle goes to the portfolio site as a pull request, so each build is reviewed on its Vercel preview before it ships. The demo on the site is an iframe served from the site's own public/ folder so that it is same-origin, so the built files have to live in that repo. GITHUB_TOKEN cannot reach another repository, so publishing uses a fine-grained PAT and a machine-owned branch that is rebuilt and force-pushed each run, keeping one pull request open instead of a pile. The tests drive the state machine a frame at a time rather than running Game.run, which loops until the window closes, and run headless under SDL's dummy driver. They lean towards the bugs the README calls out, including the two pygbag traps that only surface in a browser. build_web.py grows a --strict flag. A pygbag upgrade can change the markup the trims rewrite; without it the build would still succeed and ship an embed with a grey box around it. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 207 ++++++++++++++++++++++++++++ README.md | 60 +++++++++ build_web.py | 26 +++- requirements-dev.txt | 5 + setup.cfg | 4 + tests/__init__.py | 0 tests/test_game.py | 282 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 580 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 requirements-dev.txt create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/test_game.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..83fe1a1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,207 @@ +# Lint, test and build on every pull request; on main, also push the built +# bundle into the portfolio site. +# +# The demo on the site is an