One codebase, many portal builds: adapters and esbuild aliases
Ship one HTML5 game to CrazyGames, Poki, GameDistribution, GameMonetize, Y8 and YouTube Playables with a per-portal adapter swapped in at build time by esbuild.
Most HTML5 games end up on more than one portal. Each portal has its own SDK, its own event names and its own ad rules, and several of them forbid other portals’ ad code in your build. This guide shows a structure that keeps one copy of the game and produces one clean build per portal: the game talks to a small adapter, and esbuild picks the adapter at build time. Everything here is in the site’s repository and is built and checked by its test suite. Checked on 2026-10-01 with esbuild 0.28.
Why one build per portal
Mixing SDKs is not only a size issue; the portals’ rules point the same way:
- CrazyGames allows only ads requested through the CrazyGames SDK (advertisement requirements).
- Poki’s SDK handles all advertising and no other ad systems are allowed (requirements).
- YouTube Playables forbids in-game advertising through off-platform services (monetization requirements).
- Some SDKs must load before your own code: Playables requires its SDK script before any game code (getting started).
The Portal Readiness Checker warns when it finds SDKs for more than one portal in a zip. The setup below makes that warning impossible by construction.
The adapter interface
Every portal SDK on this site’s SDK guides is wrapped in an adapter with the same methods: loadingStart, loadingStop, gameplayStart, gameplayStop, commercialBreak(hooks), rewardedBreak(hooks) and onMuteRequest(listener), plus two optional extras: breakOnStart (GameDistribution wants a pre-roll on Play) and onSystemPause (YouTube Playables sends its own pause and resume events). The game calls only these. Where a portal has no equivalent, the adapter does nothing: GameDistribution has no gameplay events, Poki has no loading-start event, and GameMonetize documents no rewarded ads, so its rewardedBreak() resolves false without showing a prompt.
The adapters are covered in their portal guides: CrazyGames, Poki, GameDistribution, GameMonetize, Y8, YouTube Playables and the Google Ad Placement API for your own site.
The game imports a name that does not exist
The game’s entry point imports createAdapter from portal-adapter. There is no package with that name:
// file: examples/multi-portal/game.js
// One game, many portal builds. "portal-adapter" is not a real package: the build script points it
// at examples/multi-portal/portals/<portal>.js with esbuild's `alias` option, so each bundle contains
// exactly one portal's adapter and no other portal's SDK code.
import { createAdapter } from 'portal-adapter';
import { startTapGame } from '../lib/tap-game.js';
const adapter = await createAdapter();
adapter.loadingStart();
// Load assets here.
adapter.loadingStop();
startTapGame({
canvas: /** @type {HTMLCanvasElement} */ (document.getElementById('game')),
overlay: /** @type {HTMLElement} */ (document.getElementById('overlay')),
adapter,
});
console.info(`[${__PORTAL__} build] adapter: ${adapter.name}`);
Each portal gets a two-line module that exports createAdapter for its SDK, for example Poki:
// file: examples/multi-portal/portals/poki.js
import { createPokiAdapter } from '../../lib/adapters/poki.js';
export const createAdapter = () => createPokiAdapter();
and GameDistribution, which needs a game ID kept in one JSON file for all portals:
// file: examples/multi-portal/portals/gamedistribution.js
import { createGameDistributionAdapter } from '../../lib/adapters/gamedistribution.js';
import ids from '../portal-ids.json';
export const createAdapter = async () => createGameDistributionAdapter({ gameId: ids.gamedistribution });
esbuild swaps the import at build time
esbuild’s alias option substitutes one import path for another before any other resolution happens. The build script points portal-adapter at one portal module per build, and uses define to replace the __PORTAL__ identifier with the portal’s name for logging:
// file: examples/multi-portal/build.mjs
// Builds one zip-ready folder per portal from the same game code.
// node examples/multi-portal/build.mjs [portal ...] -> examples/multi-portal/dist/<portal>/
// Each folder gets index.html with that portal's SDK tag (if the SDK needs one in <head>) and a
// bundle in which the `portal-adapter` import is aliased to that portal's adapter only.
import { build } from 'esbuild';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Script tags each portal's docs ask for in <head>. GameDistribution and GameMonetize are loaded by their adapters. */
export const HEAD_TAGS = {
crazygames: ' <script src="https://sdk.crazygames.com/crazygames-sdk-v3.js"></script>',
poki: ' <script src="https://game-cdn.poki.com/scripts/v2/poki-sdk.js"></script>',
gamedistribution: '',
gamemonetize: '',
y8: ' <script src="https://cdn.y8.com/minimal-sdk/2-0/y8.min.js" async></script>',
'youtube-playables': ' <script src="https://www.youtube.com/game_api/v1"></script>',
web: '',
};
export const PORTALS = Object.keys(HEAD_TAGS);
/**
* @param {string} portal
* @param {string} outRoot
*/
export async function buildPortal(portal, outRoot = path.join(here, 'dist')) {
if (!PORTALS.includes(portal)) throw new Error(`Unknown portal "${portal}". Known: ${PORTALS.join(', ')}`);
const outDir = path.join(outRoot, portal);
await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true });
await build({
entryPoints: [path.join(here, 'game.js')],
outfile: path.join(outDir, 'main.js'),
bundle: true,
format: 'esm',
target: ['es2022'], // top-level await in game.js
minify: true,
logLevel: 'silent',
// Alias targets resolve from the working directory, so pass an absolute path.
alias: { 'portal-adapter': path.join(here, 'portals', `${portal}.js`) },
define: { __PORTAL__: JSON.stringify(portal) },
});
const template = await readFile(path.join(here, 'index.template.html'), 'utf8');
await writeFile(path.join(outDir, 'index.html'), template.replace('<!-- PORTAL_HEAD -->', HEAD_TAGS[portal]));
return outDir;
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const wanted = process.argv.slice(2);
for (const portal of wanted.length ? wanted : PORTALS) {
const dir = await buildPortal(portal);
console.log(`built ${portal.padEnd(18)} -> ${path.relative(process.cwd(), dir)}`);
}
}
Two details from the esbuild documentation shape the script:
- An aliased path is resolved from the working directory, not from the importing file, so the script passes an absolute path. A relative alias works only if you always run the build from the same folder.
definereplaces identifiers with constant expressions. Here it only labels the log line; the adapter choice itself never depends on dead-code elimination.
Run it with node examples/multi-portal/build.mjs for all portals, or name one or more (node examples/multi-portal/build.mjs poki y8). Each folder in examples/multi-portal/dist/ is ready to zip: index.html at the top level, the portal’s SDK tag where its docs require one, and a single bundle.
The HTML differs too
Some SDKs are loaded by a script tag in <head>, others by code. The template has one placeholder, and the build fills it per portal:
- CrazyGames, Poki and YouTube Playables: a plain script tag, before the game bundle.
- Y8: the same, with
async, as Y8’s getting-started page shows. The adapter handles the ready race thatasynccreates. - GameDistribution and GameMonetize: no tag. Their documented snippets set a configuration object and inject the script from code, which the adapters do at startup.
web(your own site, itch.io, anything without an SDK): no tag and a no-op adapter.
Proving each build is clean
The repository’s tests build every portal and run the Portal Readiness Checker on each output. The test fails if a build contains any SDK besides its own, if the checker reports SDKs for more than one portal, or if index.html is not at the top level. Running the same checker on your own zips before uploading catches the same mistakes on real builds.
Extending it
- A new portal is one adapter file, one two-line module in
portals/, and one entry inHEAD_TAGS. - Engine projects (Phaser, PixiJS, three.js) work the same way: the engine code imports
portal-adapter. The site’s Phaser examples use the same adapters. - Per-portal assets or settings (a different logo rule, a portal-specific save API) belong in the adapter or in another aliased module, not in
if (portal === ...)branches spread through the game.
What to check before you submit
- Each portal gets its own zip, built from the same commit.
- The checker shows exactly one SDK per zip, or none for
web. - SDK tags that must load first (Playables) come before the game bundle in
index.html. - Game IDs in
portal-ids.jsonare real, and the right one is in each build. - Every adapter’s limits are understood: no rewarded button where an adapter cannot reward.
Limits of this guide
This guide covers JavaScript builds bundled with esbuild. Vite, webpack and Rollup have equivalent alias features, which we did not test here. Engines that export their own HTML (Godot, Unity, Construct) need the portal’s own engine plugin or template instead; see each portal’s documentation.
Questions
- Can I ship one build with every portal's SDK and detect the portal at runtime?
- It is risky. CrazyGames allows only ads requested through its own SDK, Poki allows no other ad systems, and YouTube Playables forbids off-platform ad services. A runtime switch still ships the other SDKs' code. A separate build per portal avoids the question.
- Why esbuild's alias instead of if/else on a portal variable?
- Alias replaces the import before bundling, so the other adapters are never part of the bundle at all. A runtime switch, or even a define-based switch, depends on dead-code elimination to remove them.
- Do I need different index.html files per portal?
- Usually yes, because some SDKs must be loaded with a script tag in <head> (CrazyGames, Poki, Y8, YouTube Playables), while GameDistribution's and GameMonetize's documented snippets inject the script from code.