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

  1. 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.

  2. 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 like asyncio.sleep(1 / FPS) for more consistent framerate.

  3. 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.

  4. 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!

0
Subscribe to my newsletter

Read articles from Mrigank Khandelwal directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mrigank Khandelwal
Mrigank Khandelwal