I built a racing game where you draw the circuit yourself. It runs at https://apexdrawn.org/ with no download and no launcher. That decision shaped the whole repo, and it is also the reason I cannot just zip the build and upload it to a game portal.
What the repo actually is
A Vite + React + TypeScript static site. The build script is three steps:
"build": "tsc -b && vite build && node scripts/generate-seo-pages.mjs" That last step matters more than it looks. The site serves English plus Spanish, Italian, French and German pages, so the localization output is generated at build time rather than at runtime. Deploying is just shipping the dist directory to Cloudflare Pages.
Behind it there is a Worker for /api/*, a WebSocket relay at /r, Clerk for auth, and D1 migrations. The game itself lives as its own HTML document rather than as a React component tree. That split is deliberate: the marketing/site layer and the playable layer have completely different release cycles.
The part nobody warns you about
Game portals want an uploadable HTML5 package: a zip with index.html at the archive root, runnable inside their iframe. A normal Vite build does not give you that.
Absolute asset paths. Vite defaults to base: /, so dist/index.html references /assets/index-....js. A portal hosts your game on a subdirectory or its own CDN path, and those absolute URLs die. You need a separate build with --base ./ so the emitted paths are relative.
Same-origin assumptions. The playable document talks to /api/*, opens a WebSocket to /r, and initialises auth. Inside a third-party iframe, every one of those becomes a cross-origin problem. Cookies, CORS and the WebSocket handshake all behave differently from the origin you developed against.
Domain locking. My game HTML checks the hostname against an allowlist and redirects anywhere else. That is a reasonable protection for a self-hosted product and a fatal bug for a portal build. It has to become a build-time flag, not a runtime check.
I found all three while trying to publish a Game Jolt page. The form is happy to take a title, cover image, description and Website URL, but the Publish button stays disabled until a browser build is uploaded to the release. That is the platform telling you it wants a distributable artifact, not a link.
The trade I made
Browser-first means the shortest possible path from curiosity to playing: open a URL, draw a loop, race it. It also means I get static hosting, generated localized pages and normal web analytics. Packaging for portals would mean a second build target, a different auth story, and a release process for every platform.
Those are two different products. I am treating portal distribution as a separate project instead of pretending the static site can be zipped into it.
Practical checklist if you are doing both
- Add a dedicated build target with relative asset paths; do not reuse your production
dist. - Verify
index.htmlsits at the zip root and has no absolute/assets/references. - Replace hostname allowlists with a compile-time flag for embedded builds.
- Decide early whether the embedded build needs auth, an API or a relay. If it does, budget for CORS and cookie work. If it does not, strip those calls out of the embedded build entirely.
- Keep the Website field on your portal page pointing at your canonical domain. It is the one link that survives every platform's packaging rules.
ApexDrawn is free to play at https://apexdrawn.org/ . If you have shipped a static web game into a portal iframe, I would like to hear which of the above bit you first.

