How it works
There is no JavaScript version of EntLab. The website ships the actual Python program and a Python interpreter, and runs them in your tab.
Who wrote this
EntLab was written by Claude, Anthropic's AI, working from Jonah Feinberg's direction. Every Python module, this website, the documentation you're reading, and the build tooling were written by Claude across a series of sessions. Jonah set the goals, made the design calls, tested it, and said what to change. That seems worth saying plainly rather than leaving you to work it out from the commit history.
The shape of the thing
Two folders. One is an ordinary Python project; the other is a static website you can drop on any host.
EntLab/ the program
main.py entry point, for the browser build as well
entlab/ app code: world, view, menus, widgets, rulefile, …
rulesets/*.py the nine bundled rules
tests/ headless checks (python3 tests/run_all.py)
packaging/ icons, PyInstaller spec, build_web.py
EntLab Website/ the site (drag this into Netlify)
index.html home page ┐ hand-written
docs/*.html these pages │ plain HTML/CSS/JS,
style.css site.js shared │ no framework, no build step
logo.svg ┘
app/ GENERATED: EntLab compiled to WebAssembly
So / is a normal website and /app/ is the
program. The site half is deliberately boring: plain files a browser
already knows how to show.
What happens when you press Launch
- The browser loads
app/index.html, the loader page made by pygbag. That is the black "Loading, please wait…" screen. - It fetches a CPython 3.12 interpreter compiled to
WebAssembly from a CDN (
main.wasmplusmain.data). This is the roughly 15 MB, and it is cached from then on. - The interpreter starts and unpacks
entlab.apk, which is a ZIP ofmain.py,entlab/andrulesets/, into a filesystem held in memory. As far as Python is concerned, those are now real files. - It reads the
# /// scriptblock at the top ofmain.pyto learn what the program needs, and downloads those wheels from the same CDN: pygame-ce and NumPy. - It runs
main.pyas__main__, the same file you would run in a terminal.
From there it is the program itself, unchanged. main.py
notices it is running under Emscripten, skips the pip install step the
desktop version does on first run, and starts the app.
The one structural change
A desktop game loop is while True:. In a browser that
locks the tab forever, because JavaScript never gets control back to
paint anything or handle a click.
So the app loop runs the same frame function as the desktop build, but yields once per frame:
async def run_async(self):
while self._frame():
await asyncio.sleep(0) # hand the frame back to the browser
One quirk worth knowing: under pygbag, asyncio
is pygbag's own scheduler. asyncio.run() doesn't
block. It puts the coroutine on a requestAnimationFrame
loop and returns straight away.
pygame draws into an SDL surface, and Emscripten blits that to a
<canvas> filling the window.
Fitting the window
A browser tab has no window manager, so nothing sends the resize event
a desktop app waits for. The app reads
window.innerWidth / innerHeight
directly and re-checks a few times a second, re-taking the display mode
when they drift apart. It also subtracts the height of the EntLab bar
at the top of the page, so the board is never hidden underneath it.
Gestures the browser gets wrong
Two input paths needed building by hand.
Trackpad pinch. Every browser reports a pinch as a scroll event with the Ctrl flag set, without ever sending a real Ctrl keypress. SDL only knows about keys it actually saw, so the app read the gesture as an ordinary scroll and panned instead of zooming. A small piece of JavaScript now catches those events, stops the page itself from zooming, and passes the app the amount to zoom by.
Touchscreen pinch. On a phone this isn't a scroll event at all. SDL turns your first finger into a mouse drag, which is what lets you draw, but the second finger is dropped, so there was no way to zoom or pan. The same code now tracks both fingers, turns them into zoom and pan, and tells the app to throw away the stroke your first finger had already started, so pinching doesn't smear a line across the board.
On a touch device the app also switches to a chunkier layout: a taller tool row with finger-sized buttons, and a toolbar that wraps onto extra rows instead of running off the edge of a narrow screen.
What differs from the desktop app
- Files don't stick around. The filesystem lives in memory, so
worlds, settings and logs are gone when you reload. Download a
.gridif you want to keep it. - The first visit is slow and later ones aren't, because that 15 MB is cached.
- It runs slower than the desktop app, since everything goes through WebAssembly. The simulation gets a smaller slice of each frame here so the interface stays responsive under a heavy world, and the status bar shows the rate it is really managing.
Everything else (the menus, the tools, the keybinds, the rulesets, the history scrubber) is literally the same code.
Building and deploying
One command regenerates the compiled half. It only ever replaces
app/, so the home page and these docs are never touched:
python3 packaging/build_web.py
It copies main.py, entlab/ and
rulesets/ into a temporary folder, runs pygbag over them,
moves the result into the website folder, and restyles pygbag's loader
page to match the site.
Deploying is dragging the EntLab Website folder onto
Netlify Drop. About a
megabyte of static files; no server, no build step, no configuration.
GitHub Pages and Cloudflare Pages work identically.
The test suite unzips the built archive and compares every module against the project byte for byte, so an out-of-date build fails loudly instead of quietly shipping old code.