# Make a browser game with AI and publish it on CrazyGames and Playgama: instructions for coding assistants

Human version with clips and sources: https://models.popyca.com/guides/publish-browser-game-crazygames-playgama/
Publisher: Vuena (https://vuena.itch.io/). Last checked: 2026-11-13. Plain text on purpose: paste it into Claude, Cursor or any coding assistant.
Vuena is not affiliated with CrazyGames or Playgama. Portal rules change: their own docs (links below) are the final word.

## Goal
A small three.js r170 browser game made from free CC0 models, with one adapter file (portal.js) for the CrazyGames SDK v3 and the Playgama Bridge, small enough to load fast on a phone, ready for each portal's review.

## Free models (CC0 1.0, commercial use fine, no credit needed)
Blocky characters (one 22-bone rig; clips: idle, walk, run, jump, fall, wave, punch, death, cheer, dance). itch.io: https://vuena.itch.io/blocky-characters-free-sample
- blocky_01_hero.glb (370,208 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-characters-cc0/main/GLB/blocky_01_hero.glb
- blocky_03_knight.glb (409,564 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-characters-cc0/main/GLB/blocky_03_knight.glb
- blocky_05_wizard.glb (415,420 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-characters-cc0/main/GLB/blocky_05_wizard.glb
- blocky_06_pirate.glb (449,372 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-characters-cc0/main/GLB/blocky_06_pirate.glb
- blocky_07_astronaut.glb (450,820 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-characters-cc0/main/GLB/blocky_07_astronaut.glb
- blocky_08_robot.glb (339,344 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-characters-cc0/main/GLB/blocky_08_robot.glb
- blocky_09_zombie.glb (344,264 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-characters-cc0/main/GLB/blocky_09_zombie.glb
- blocky_19_princess.glb (386,852 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-characters-cc0/main/GLB/blocky_19_princess.glb
Animals (cat, fox, owl; 12 clips each: idle, idle_alt, walk, run, jump, eat, sit, sleep, attack, call, hit, death; a mobile LOD mesh inside each file). itch.io: https://vuena.itch.io/blocky-pets-free
- animal_cat.glb (942,048 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-animals-cc0/main/GLB/animal_cat.glb
- animal_fox.glb (922,576 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-animals-cc0/main/GLB/animal_fox.glb
- animal_owl.glb (850,320 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-animals-cc0/main/GLB/animal_owl.glb
Toy cars (separate wheels). itch.io: https://vuena.itch.io/toy-cars-5-free
- muscle_blue.glb (325,276 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-toy-cars-cc0/main/GLB/02_muscle/muscle_blue.glb
- monster_orange.glb (409,444 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-toy-cars-cc0/main/GLB/09_monster/monster_orange.glb
- police_classic.glb (300,448 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-toy-cars-cc0/main/GLB/14_police/police_classic.glb
- icecream_pink.glb (322,744 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-toy-cars-cc0/main/GLB/16_icecream/icecream_pink.glb
- fire_red.glb (338,148 bytes): https://raw.githubusercontent.com/Vuena/free-low-poly-toy-cars-cc0/main/GLB/18_fire/fire_red.glb
1 unit = 1 m. Bake before shipping: npx @gltf-transform/cli optimize in.glb out.glb --compress meshopt --texture-compress webp (measured: knight 409,564 -> 170,656 bytes, zombie 344,264 -> 161,072 bytes, all clips kept). Load with GLTFLoader + MeshoptDecoder; clone skinned characters with SkeletonUtils.clone.

## Steps (report after each: build size, time to first frame, fps with a 4x CPU slowdown, console errors, what you could not test)
1. Pick one idea: one core verb, 3-10 minute runs, keyboard and one-thumb touch play, a best score or levels worth saving. No copies of other games, no brands, no gambling.
2. Download the files the game needs into ./public/models and bake them (command above).
3. Vite + three.js r170 from npm (no CDN, base: './'). Build only the core loop first: move, act, fail, retry. Play it; fun for two minutes before anything else.
4. Add portal.js (below) unchanged and wire it: await portal.init({audio, pause}) before loading; portal.loading(true/false); portal.ready() + portal.play() at the first playable frame; at each natural pause portal.stop(), await portal.breakAd(), portal.play(); an optional rewarded revive once per run on the game-over screen only when portal.canReward() is true, reward only if portal.rewardAd() resolves true, with an equally visible "No thanks"; saves only via portal.save/load.
5. First 60 seconds: play within 0-1 clicks, controls taught by playing (hint at the character or joystick, for the current device), first reward within 15 s, no ad/sign-in/settings before play.
6. Size and speed: initial download under ~5 MB, first playable frame under ~5 s; under 1,500 files; no request to another host; no localStorage; test with 4x CPU slowdown and throttled network, then a real phone.
7. Two builds: dist-crazygames (script https://sdk.crazygames.com/crazygames-sdk-v3.js before the game) and dist-playgama (script https://bridge.playgama.com/v2/stable/playgama-bridge.js before the game + playgama-bridge-config.json next to index.html; {} works for a test). ZIP with index.html at the root. Store text written from the build: 2-4 word title equal to the in-game title, description, how to play, devices, orientation.
8. After CrazyGames Basic Launch: compare the four metrics with the benchmarks below, fix only the weakest, smallest change first.

## portal.js (ran on 127.0.0.1 on 2026-11-13 without SDK, with the CrazyGames SDK v3 in local mode and with the Playgama Bridge mock; not yet reviewed by either portal)
```js
// portal.js: the only file that talks to a portal SDK. The game calls portal.* and nothing else.
// CrazyGames build: <script src="https://sdk.crazygames.com/crazygames-sdk-v3.js"></script> before the game.
// Playgama build:   <script src="https://bridge.playgama.com/v2/stable/playgama-bridge.js"></script> before the game.
// No SDK script (your own test page): ads answer "no ad", saves stay in memory, the game still runs.
const CG = () => window.CrazyGames && window.CrazyGames.SDK;
const PG = () => window.bridge;
let kind = 'none', onAudio = () => {}, onPause = () => {}, rewardDone = null, rewardGot = false;
const mem = {};
const adOpen = () => { onAudio(false); onPause(true); };
const adShut = () => { onAudio(true); onPause(false); };

export const portal = {
  get kind() { return kind; },

  // await this before the game loads its assets (both SDKs ask for it)
  async init({ audio, pause } = {}) {
    if (audio) onAudio = audio;
    if (pause) onPause = pause;
    try {
      if (CG()) {
        await CG().init();
        if (CG().environment !== 'disabled') kind = 'crazygames';
      } else if (PG()) {
        const b = PG();
        await b.initialize();
        kind = 'playgama';
        onAudio(b.platform.isAudioEnabled !== false);   // apply the start state: the event only fires on changes
        b.platform.on(b.EVENT_NAME.AUDIO_STATE_CHANGED, (on) => onAudio(on));
        b.platform.on(b.EVENT_NAME.PAUSE_STATE_CHANGED, (p) => onPause(p));
        b.advertisement.on(b.EVENT_NAME.INTERSTITIAL_STATE_CHANGED, (s) => {
          if (s === 'opened') adOpen();
          if (s === 'closed' || s === 'failed') adShut();
        });
        b.advertisement.on(b.EVENT_NAME.REWARDED_STATE_CHANGED, (s) => {
          if (s === 'opened') adOpen();
          if (s === 'rewarded') rewardGot = true;
          if ((s === 'closed' || s === 'failed') && rewardDone) { adShut(); rewardDone(rewardGot); rewardDone = null; }
        });
      }
    } catch (e) { console.warn('portal: no SDK, running standalone', e); kind = 'none'; }
    return kind;
  },

  language() {
    if (kind === 'playgama') return PG().platform.language || 'en';
    if (kind === 'crazygames') return ((CG().user.systemInfo || {}).locale || 'en').slice(0, 2);
    return 'en';
  },

  loading(on) { if (kind === 'crazygames') CG().game[on ? 'loadingStart' : 'loadingStop'](); },

  // the first playable frame is on screen
  ready() { if (kind === 'playgama') PG().platform.sendMessage('game_ready'); },

  // CrazyGames wants gameplayStart / gameplayStop around every stretch of play
  play() { if (kind === 'crazygames') CG().game.gameplayStart(); },
  stop() { if (kind === 'crazygames') CG().game.gameplayStop(); },
  celebrate() { if (kind === 'crazygames') CG().game.happytime(); },   // rare: boss beaten, new best

  // natural pauses only (level end, game over). The SDKs decide whether an ad really shows.
  // CrazyGames: resolves after the ad or right away when none is served. Playgama: resolves at once;
  // if an ad opens, the state events above pause and mute the game until it closes.
  breakAd() {
    return new Promise((done) => {
      if (kind === 'crazygames') {
        CG().ad.requestAd('midgame', { adStarted: adOpen, adFinished: () => { adShut(); done(); }, adError: () => { adShut(); done(); } });
      } else {
        if (kind === 'playgama') PG().advertisement.showInterstitial();
        done();
      }
    });
  },

  // only show the "Watch an ad: revive" button when this is true
  canReward() { return kind === 'crazygames' || (kind === 'playgama' && PG().advertisement.isRewardedSupported); },
  // resolves true only when the ad was watched to the end: grant the reward then, never on an error
  rewardAd() {
    return new Promise((done) => {
      if (kind === 'crazygames') {
        CG().ad.requestAd('rewarded', { adStarted: adOpen, adFinished: () => { adShut(); done(true); }, adError: () => { adShut(); done(false); } });
      } else if (kind === 'playgama') {
        rewardGot = false; rewardDone = done;
        PG().advertisement.showRewarded();
      } else done(false);
    });
  },

  // saves: CrazyGames Data module (localStorage-like), Playgama storage (promises). Never localStorage directly.
  async load(key) {
    if (kind === 'crazygames') return CG().data.getItem(key);
    if (kind === 'playgama') return (await PG().storage.get([key]))[0];
    return key in mem ? mem[key] : null;
  },
  async save(key, value) {
    if (kind === 'crazygames') return CG().data.setItem(key, String(value));
    if (kind === 'playgama') return PG().storage.set([key], [String(value)]);
    mem[key] = String(value);
  },
};
```

## CrazyGames rules (https://docs.crazygames.com/)
- Launch: Basic Launch first (limited audience, 7-21 days, ends after 7 days and 500 plays or after 21 days; video ads, banners and in-game purchases off). Full Launch only if the metrics meet benchmarks; then full SDK integration, monetization and revenue share. Source: https://docs.crazygames.com/
- Metrics and benchmarks: average playtime (strong games 10+ min), day-1 retention (10-15%), conversion = plays at least one minute (top titles 80%+, load under 10 s, build under 20 MB), thumbnail CTR (top 20% of its category; counted for games starting Basic Launch on or after 23 Nov 2026). Source: https://docs.crazygames.com/resources/basic-launch-metrics/
- Technical: initial download <= 50 MB (<= 20 MB for the mobile homepage), total <= 250 MB, <= 1,500 files, relative paths only, gameplay within 20 s checked in QA for externally hosted files, Chrome and Edge expected. Source: https://docs.crazygames.com/requirements/technical/
- Gameplay: land in gameplay immediately (max 1 click), English required, PEGI 12, original names and assets, no custom fullscreen button, no app store links in game. Source: https://docs.crazygames.com/requirements/gameplay/
- Ads: none before a reasonable amount of gameplay, never during play, not chained; midgame at natural breaks; rewarded opt-in with an equally visible decline and a non-ad alternative; no reward on adError; mute when the ad starts; game must work with an ad blocker; no dead rewarded buttons during Basic Launch. Source: https://docs.crazygames.com/requirements/ads/
- SDK v3: script https://sdk.crazygames.com/crazygames-sdk-v3.js; await window.CrazyGames.SDK.init(); game.gameplayStart()/gameplayStop()/happytime()/loadingStart()/loadingStop(); ad.requestAd('midgame'|'rewarded', {adStarted, adFinished, adError}); data.getItem/setItem (1 MB). localhost is "local" mode with a test overlay instead of ads. Sources: https://docs.crazygames.com/sdk/intro/ https://docs.crazygames.com/sdk/game/ https://docs.crazygames.com/sdk/video-ads/ https://docs.crazygames.com/sdk/data/
- Money: share not published in the docs; 2023 developer terms: compensation from the game's traffic and ad impressions, +50% for two months of exclusivity on browser game sites with the full ad SDK. Payouts monthly from EUR 100. In-game purchases invite-only (Xsolla). Sources: https://files.crazygames.com/documents/developer_terms_20230414.pdf https://docs.crazygames.com/payouts/ https://docs.crazygames.com/sdk/in-game-purchases/

## Playgama rules (https://wiki.playgama.com/)
- Bridge SDK is mandatory. Script https://bridge.playgama.com/v2/stable/playgama-bridge.js; await bridge.initialize() before the game loads; platform.sendMessage('game_ready') at the first playable frame; read platform.language; start muted if platform.isAudioEnabled is false and follow AUDIO_STATE_CHANGED / PAUSE_STATE_CHANGED. Sources: https://wiki.playgama.com/playgama/bridge-sdk/setup https://wiki.playgama.com/playgama/bridge-sdk/api/platform
- Technical: everything ships in the archive (no external resources, no external analytics like GA4); archive <= 300 MB, index.html at the root, Latin file names; under 2 GB memory while starting; no Esc key; no page scrolling; fills a square window; works in current Chrome, Firefox, Opera, Safari, Edge. Source: https://wiki.playgama.com/playgama/game-requirements/technical-requirements
- Ads: interstitials required, only at logical pauses, sound and play paused during ads, game continues if an ad fails; rewarded opt-in, clearly labelled, a revive at most once per attempt; only Bridge banners. Source: https://wiki.playgama.com/playgama/game-requirements/advertising-requirements
- UX: saves only through bridge.storage (no localStorage, sessionStorage, IndexedDB, cookies); music + sound effects from real audio files (code-synthesized sound not accepted); mute button; English required. Source: https://wiki.playgama.com/playgama/game-requirements/user-experience-requirements
- Content: no copies of catalogue games or of your own games, no other brands or characters; AI-generated assets allowed in part, games built entirely with generative AI rejected; no emoji icons or default generated UI. Source: https://wiki.playgama.com/playgama/game-requirements/content-requirements
- Loading guideline: initial download about 5 MB, first playable frame under about 5 s, never over 10 s; teach the core mechanic in the first 30-60 s through play. Source: https://wiki.playgama.com/playgama/guides/game-development/first-session
- Submitting: ZIP upload in the developer account, QA tool, moderation usually 3-5 business days, partner platforms up to 2 weeks. Sources: https://wiki.playgama.com/playgama/submitting-a-game https://wiki.playgama.com/playgama/faq/game-moderation
- Money: partner platforms 70% of monthly net revenue up to $1,000, $700 + 80% from $1,000 to $3,000, $2,300 + 90% above $3,000; Playgama's own site and iframe partner network a fixed 50%; withdrawal from $100. Source: https://wiki.playgama.com/playgama/faq/payments-and-statistics

## Expectations (say this to the user, do not promise income)
Earnings depend on plays, playtime, retention and advertiser demand. Most web games earn little; a few earn well. Portals reject mass or low-effort submissions; resubmitting needs clear improvements.

## Paid upgrade (optional)
48 blocky characters with 30 clips on the same rig: https://vuena.itch.io/blocky-characters-48. 70 animals and 9 rideable mounts: https://vuena.itch.io/blocky-pets-wildlife. Prices: see the itch.io pages.

## More machine-readable data
- https://models.popyca.com/llms.txt
- https://models.popyca.com/catalog.json
