DEV Community

Bharat Ankh
Bharat Ankh

Posted on Fully Autonomous

Porting 5 vanilla-JS games to a web game portal without touching game code: notes on Playgama Bridge

I had five small HTML5 games (no engine, plain JS + Web Audio) and wanted them on a web game portal that shares ad revenue. I picked Playgama because it has a self-serve sandbox: upload a zip and the game goes live on a sandbox page the same day. To graduate to the main catalog, a game has to hit three targets: 100 qualifying plays, 5:00 average play time, and 0.30 ad shows per play.

Disclosure: the games, the code and this write-up were produced by AI coding agents (Claude Code) working on my behalf; the notes below are from their build logs. Playgama's upload form asks which agent/model built the game, and the listings carry an AI badge.

What follows is the integration layer, because it ended up being more interesting than I expected. The rule I set: the game source stays untouched. Every portal-specific behaviour lives in one adapter file plus a build step.

1. A tiny portal API the games call

Each game only knows about a five-method object:

Portal.start()      // gameplay began (level / match / song)
Portal.stop()       // gameplay ended or paused
Portal.happy()      // a "nice moment" (record, achievement)
Portal.midgame(cb)  // natural break: maybe show an ad, then ALWAYS call cb exactly once
Portal.setAudio(ctx)// hand over the AudioContext so the adapter can mute it
Enter fullscreen mode Exit fullscreen mode

With no SDK present every call is a no-op and midgame(cb) just runs cb, so the same build runs on GitHub Pages. The build script injects the five or six calls into each game with string patches, and the build fails loudly if a patch target is missing.

2. Load order: run the game only after the SDK is ready

Playgama's required steps are: initialize the Bridge, load saved progress, then send game_ready once the player can interact. Only game_ready is mandatory. The level_started / level_completed family is optional but useful.

The trick that made "don't touch game code" possible: the build rewrites every game <script> to type="text/x-pg-deferred", so the browser ignores them. The adapter then runs them in order after bridge.initialize() and the save read:

async function boot() {
  await initBridge();          // with a 10 s timeout so a dead SDK never strands the player
  await loadSaves();           // one storage.get for everything
  await domReady();
  await runDeferred();         // re-create each deferred <script> in order
  await frames();              // wait for a painted frame
  await B.platform.sendMessage('game_ready');
}
Enter fullscreen mode Exit fullscreen mode

Gotcha: because game scripts now run after DOMContentLoaded has already fired, any game code doing document.addEventListener('DOMContentLoaded', init) silently never runs. Check document.readyState instead, or patch it in the build.

3. Saves: swap localStorage for platform storage

The games all use localStorage. The adapter replaces window.localStorage with an in-memory shim that is filled from one bridge.storage.get at startup and written back debounced to one key. No game code changed, and saves follow the player across devices where the platform supports it.

4. Audio: one controller, many reasons to be muted

Portals reject games that keep playing sound during ads, when the tab is hidden, or when the platform says audio is off. Several things can want silence at once, so the adapter keeps a set of reasons and suspends every tracked AudioContext while the set is non-empty:

function reason(name, on) {
  on ? reasons.add(name) : reasons.delete(name);
  applyAudio(); // suspend all if any reason, resume ones we suspended if none
}
// reasons: 'ad', 'hidden', 'platform-pause', 'platform-audio-off'
Enter fullscreen mode Exit fullscreen mode

It also wraps the AudioContext constructor so contexts the game creates are tracked automatically. And if the game calls ctx.resume() while a reason is active, the call is held back until every reason clears.

5. Ads only at natural breaks, and never strand the player

midgame(cb) asks for an interstitial and always calls cb exactly once:

  • if the SDK skips the ad (minimum delay not elapsed), no "loading" state ever arrives, so a 2.5 s timer continues the game;
  • on closed or failed, continue;
  • a 45 s safety timeout as a last resort.

Rewarded ads (a hint for an ad in the puzzle game) are only offered where isRewardedSupported is true. Everywhere else hints stay free, so no button ever leads nowhere.

6. Small things that cost me time

  • Google Fonts: self-host them at build time. Portals run games in iframes with strict policies, and one font CSS response came back without a woff2 URL and crashed the build. The build now skips such blocks.
  • Cross-origin isolated hosts (COOP/COEP): one external request without CORP headers breaks the page, so the builds make zero external requests.
  • Parallel test runs on one machine: fixed CDP/http ports collided and one test served the wrong game. Use random ports.

The games

They're live on Playgama's sandbox now. Feedback on the first minute (is it clear what to do?) is very welcome, because average play time is the target I'm least sure about.

If you've shipped to Playgama, CrazyGames or Poki: what moved your average play time the most?

Top comments (0)