Running Python Games in the Browser: Pygbag + GitHub Pages

Introduction
Locally developed Python games are cool, but let’s be real — no one’s downloading a .zip, granting weird macOS permissions, and double-clicking a sketchy .py launcher just to try your game. If only you could somehow push it to the web, send your friends a link, and flex.
Well, guess what? You can.
With Pygbag, you can compile your PyGame project into WebAssembly and host it anywhere, like GitHub Pages — no installs, no setup.
This article walks through how to port an existing PyGame project to run in the browser using Pygbag, and how to host it publicly via GitHub Pages with minimal effort.
⚠️ Quick heads up: your game runs fully in the browser, not on a server. That means heavy or resource-intensive games might not perform as smoothly, especially on lower-end devices.
TL;DR
Turn your PyGame project into a browser game using Pygbag + GitHub Pages.
Prerequisites
A working game project built with PyGame
Basic Python + a rough idea of what WebAssembly is
GitHub basics
A little about Pygbag
Pygbag is a specialized toolchain that solves a fundamental problem: Python and Pygame are not natively supported in browsers. Browsers run JavaScript (or WebAssembly), not Python. Traditionally, if you wrote a game in Pygame, the only way to share it was as a downloadable desktop app.
Pygbag compiles Python (and specifically PyGame projects built using pygame-ce) into WebAssembly using Pyodide under the hood. Pyodide ports CPython to WebAssembly, built via Emscripten, which means it can run standard Python code in the browser along with many scientific and game-related packages.
When you build your game with Pygbag, it:
Bundles your python files and dependencies into a single WebAssembly-friendly archive
Adds a lightweight Python runtime (Pyodide) to execute your code inside the browser
Launches a virtual filesystem in-memory to simulate access to files like assets and saves
You’re not running Python on a server. Everything runs entirely in the client’s browser, no installation required. It’s effectively Python compiled into a WASM sandbox — with a bit of scaffolding to make it game-ready.
Using Pygbag on existing Pygame projects
Pygbag is built to run pygame projects in the browser — but it doesn’t work out-of-the-box with just any pygame code. This section explains why. You can skip this section and directly go to the next section which delineates the steps to convert your pygame project to a Pygbag compatible project.
Pygbag only works with projects developed using the community edition of pygame, pygame-ce. Luckily, this just means changing the pygame package to the pygame-ce package since they are cross compatible.
Moreover, most pygame projects are written using synchronous, blocking game loops. A classic pygame example looks something like:
while running:
handle_events()
update_game_state()
draw()
clock.tick(60)
Browsers rely on a single-threaded event loop. They expect applications to yield control periodically so that rendering, user input, and other tasks can continue smoothly. In simple terms, your game needs to occasionally pause (for example, using a short sleep), give control back to the browser, and then resume. The game loop shown earlier is a blocking loop because it runs continuously without yielding. When run in a browser, this causes the entire tab to become unresponsive. To make the game browser-friendly, we need to replace these blocking loops with asynchronous loops that periodically yield control back to the browser.
Porting an existing Pygame project to Pygbag
In this article, I use a dummy Pygame codebase and show steps to port it to a Pygame compatible codebase.
Original Pygame Codebase
'''
main.py
'''
from menu import show_menu
from game import run_game
import pygame
def main():
pygame.init()
screen = pygame.display.set_mode((640, 480))
pygame.display.set_caption("Pygame WASM Demo")
show_menu(screen)
run_game(screen)
if __name__ == "__main__":
main()
'''
menu.py
'''
import pygame
import sys
from constants import *
def show_menu(screen):
font = pygame.font.SysFont(None, 48)
running = True
while running:
screen.fill(BLACK)
text = font.render("Press SPACE to Start", True, WHITE)
screen.blit(text, (WIDTH // 2 - text.get_width() // 2, HEIGHT // 2))
pygame.display.flip()
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
running = False
'''
pause.py
'''
import pygame
import sys
from constants import *
def pause_loop(screen):
font = pygame.font.SysFont(None, 36)
paused = True
while paused:
screen.fill(DARK_GRAY)
msg = font.render("Paused - Press R to Resume", True, WHITE)
screen.blit(msg, (WIDTH // 2 - msg.get_width() // 2, HEIGHT // 2))
pygame.display.flip()
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_r:
paused = False
'''
constants.py
'''
WIDTH = 640
HEIGHT = 480
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
RED = (255, 0, 0)
DARK_GRAY = (30, 30, 30)
run-local:
python3 -m venv pygame-venv && \
. pygame-venv/bin/activate && \
pip install --upgrade pip setuptools wheel && \
pip install -r requirements.txt && \
python main.py
publish:
python3 -m venv pygame-venv && \
. pygame-venv/bin/activate && \
pip install --upgrade pip setuptools wheel && \
pip install -r requirements.txt && \
pygbag --build . && \
rm -rf docs && \
mkdir -p docs && \
cp -r build/web/* docs/ && \
git add docs && \
git commit -m "Deploy to GitHub Pages" && \
git push
'''
requirements.txt TO REMOVE THE COMMENTED SECTION
'''
pygame
pygbag
Step 1: Use Pygame ce
In requirements.txt, use pygame-ce instead of pygame. This does not impact the working of your game.
requirements.txt TO REMOVE THE COMMENTED SECTION
'''
pygame-ce
pygbag
Step 2: Add __init__.py
to Your Project Folders
If your project uses a directory structure with subfolders (like menu/
, utils/
, or scenes/
), make sure each of those folders contains a file named __init__.py
. This file can be empty. Its presence simply tells Python to treat the folder as a module. Even if your project works locally without it, adding __init__.py
ensures your code is portable, clean, and future-proof.
Step 3: Run your game using asyncio.run in main.py
For the main.py, you need to run it using asyncio.run(main())
'''
main.py
'''
import pygame
import asyncio
from menu import show_menu
from game import run_game
async def main():
pygame.init()
screen = pygame.display.set_mode((640, 480))
pygame.display.set_caption("Pygame WASM Demo")
await show_menu(screen) #Show_menu has blocking loop;convert to async&await
await run_game(screen)
if __name__ == "__main__":
asyncio.run(main())
Step 4: Convert blocking game loops to async
Use the asyncio library to add a sleep in all these loops. Ensure that functions containing these loops are async functions and they are called using await.
'''
game.py
'''
import pygame
import asyncio
import random
from constants import *
async def run_game(screen): # this function is now asynchronous
clock = pygame.time.Clock()
ball_pos = [WIDTH // 2, HEIGHT // 2]
ball_vel = [3, 3]
radius = 20
running = True
while running:
screen.fill((0, 0, 0))
pygame.draw.circle(screen, (255, 0, 0), ball_pos, radius)
pygame.display.flip()
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
for i in range(2):
ball_pos[i] += ball_vel[i]
if ball_pos[i] - radius < 0 or ball_pos[i] + radius > (WIDTH if i == 0 else HEIGHT):
ball_vel[i] *= -1
# Yield control so browser does not freeze
await asyncio.sleep(0)
'''
menu.py
'''
import pygame
import sys
import asyncio
from constants import *
async def show_menu(screen):
font = pygame.font.SysFont(None, 48)
running = True
while running:
screen.fill(BLACK)
text = font.render("Press SPACE to Start", True, WHITE)
screen.blit(text, (WIDTH // 2 - text.get_width() // 2, HEIGHT // 2))
pygame.display.flip()
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
running = False
await asyncio.sleep(0)
'''
continue.py
'''
import pygame
import sys
import asyncio
from constants import *
async def pause_loop(screen):
font = pygame.font.SysFont(None, 36)
paused = True
while paused:
screen.fill(DARK_GRAY)
msg = font.render("Paused - Press R to Resume", True, WHITE)
screen.blit(msg, (WIDTH // 2 - msg.get_width() // 2, HEIGHT // 2))
pygame.display.flip()
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_r:
paused = False
await asyncio.sleep(0)
In this dummy project, we had 3 blocking loops, in game.py, menu.py and pause.py. I added a sleep after each iteration of the loop so it yields control after one execution cycle. With those changes, your existing Pygame project is now compatible with Pygbag and ready to be compiled to WebAssembly!
Deploying to GitHub Pages
Use the Makefile as a reference. Assuming you are in the right directory, all you need to do is run the following in the terminal:
make publish
If you do this, you can go straight to step 5. Otherwise, you can follow each step below and manually compile and push your code to GitHub.
Step 0(optional): Create a clean deploy branch
Create a new branch called deploy. This branch contains all your deployment related files
Step 1: Use a Python virtual environment and install dependencies:
python3 -m venv pygame-venv
source pygame-venv/bin/activate
pip install --upgrade pip setuptools wheel
pip install -r requirements.txt
Step 2: Compile to WASM using Pygbag
pygbag --build .
Step 3: Create a new docs folder and copy your code
This folder will be used by GitHub pages to serve your content and app.
rm -rf docs
mkdir -p docs
cp -r build/web/* docs/
Step 4: Push the code to GitHub
git add docs
git commit -m "Deploy to GitHub Pages"
git push origin <-deploy-branch-name->
Step 5: Launch GitHub pages
Go to your GitHub repo -> Settings -> Pages.
Select the name of your deploy branch under the Branch section. In my example, the branch I used was “pygbag-compatible”. Also select the /docs folder. This tells GitHub pages where to look for the assets and the Html file.
Press save and voila! Your game should be live in a few minutes!
Gotchas When Porting to Pygbag
I tried spinning up a local server to run my PyGame however for some reason, it kept using non-existant wheels from Python 3.12 even after containerizing and launching in a docker environment.
If you notice that your game suddenly zooms through frames like it drank three Red Bulls; it’s probably because of
asyncio.sleep(0)
. A zero-second sleep technically yields control but doesn’t actually throttle your game loop. Try using a small delay likeasyncio.sleep(1 / FPS)
for more consistent framerate.If your game uses mp3 files for background music or sound effects, you might find that they don’t play correctly once ported to the browser. This is a common compatibility issue when running Pygame through WebAssembly. The easiest fix is to convert all your audio assets to ogg format, which has much better support in browser environments.
Protip: You can go to: <-your-gh-pages-url>/#debug to see the debug logs! They can be super helpful in understanding what was breaking in your app!
Conclusion
Hopefully, this article helps you successfully bring your PyGame project to the web using Pygbag and GitHub Pages. Whether you’re sharing it with friends or showing off your browser-based game dev skills, it’s a satisfying milestone to hit. Happy porting, and may your frame rates stay smooth!!!
I am Mrigank Khandelwal, a Computer Science graduate student. I came across the world of games through my course GameAI, where I built DarWars, a 2D Shooting game that utilizes Genetic Algorithms to develop its AI. When I shared my game with my friends, many had trouble downloading and running it; the app needed some permissions in MacOS. This prompted me to attempt to host the game online so anyone can easily play it. I am always open to chat about Software, AI and everything in between; feel free to connect with me on LinkedIn!
Subscribe to my newsletter
Read articles from Mrigank Khandelwal directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
