Skip to content

Ads & Sharing

On a mini-game host (WeChat, Douyin), monetization and sharing are platform calls every game wires up by hand — and the wiring is the part that goes wrong: the game keeps simulating under a rewarded video, audio keeps playing under the ad, an error path leaves the world paused forever. The Ads and Share services own that ceremony, and they exist on every platform, so gameplay code is written once and asks what this platform can do instead of branching on it.

Ads.showRewarded(adUnitId) shows a rewarded video and resolves — after the ad closed and the game is running again — with whether the reward was earned:

import { defineSystem, Res, GetWorld, Ads } from 'esengine';
const reviveSystem = defineSystem([Res(Ads), GetWorld()], (ads, world) => {
if (!reviveClicked(world)) return;
ads.showRewarded('adunit-xxxx').then(({ completed }) => {
if (completed) revivePlayer(world); // watched to the end → grant the reward
else showToast(world, 'Watch the whole ad to revive');
}).catch(() => {
showToast(world, 'No ad available right now');
});
}, { name: 'ReviveSystem' });

While the ad covers the screen, the service:

  • pauses the game clock — a revive must not cost the player their run;
  • suspends the audio device — without touching any volume the user set;
  • restores both however the ad ends, including every error path. A game you paused yourself before the ad stays paused after it.

The hosts’ load/show dance (an ad with no fill must be loaded and retried once) is folded in, as is the runtime that grants a reward without reporting isEnded — an absent close record counts as completed, because that is what the host meant.

Ads.showInterstitial(adUnitId) is the same contract minus the reward, and Ads.preloadRewarded(adUnitId) warms a unit so the show starts instantly.

Ads.available is true where SOME ad source exists — the platform’s, or an installed provider. Use it to hide the “watch ad” button honestly:

  • WeChat / Douyin builds — the host’s rewarded/interstitial ads, one family implementation.
  • The editor’s Play mode — a mock provider is installed automatically: showRewarded “plays” for a moment and resolves completed: true, with the real pause/audio ceremony running. Your revive flow rehearses at your desk.
  • Web and native builds — no ad system, available is false. A native shell that integrates a mediation SDK installs it through the same door the editor uses: Ads.setProvider(...).

Sharing and in-game purchase ship as a package rather than inside the engine — most games open neither:

Terminal window
npm install estella-plugin-minigame-services
src/main.ts
import { addPlugin } from 'esengine';
import { miniGameServicesPlugin } from 'estella-plugin-minigame-services';
addPlugin(miniGameServicesPlugin);

A mini-game host has two share surfaces, and a game should configure both:

import { defineSystem, addStartupSystem, Res } from 'esengine';
import { Share } from 'estella-plugin-minigame-services';
addStartupSystem(defineSystem([Res(Share)], (share) => {
// The DEFAULT card: what the host's own share menu (top-right on WeChat)
// shows, and what share() without arguments uses. A function is asked at
// share time, so the card can carry live state — a score, a room code.
share.setShareCard(() => ({
title: `I scored ${currentScore()} — beat me!`,
query: `room=${currentRoomCode()}`,
}));
}, { name: 'SetupShare' }));
// In the game's own share button handler (share is Res(Share) as above):
share.share(); // the default card
share.share({ title: 'Join my game' }); // or a one-off card

Whoever opens the shared card receives query in the host’s launch options — that is how invite links and room codes travel.

Sharing is fire-and-forget by design: since 2021 no mini-game host reports whether the player actually shared, so there is nothing to await. Off-platform (web, native, the editor) Share.available is false and share() returns false — hide the button rather than promise a sheet that cannot open.

A mini-game leaderboard is not a list you fetch. The player’s friends can be read only inside the open data context — a second JavaScript runtime the host starts beside your game, with no engine, no WebGL and no wasm in it — and there is no channel back from that runtime to yours. So the board is drawn over there and arrives here as pixels.

It ships as a package rather than in the engine, because a game that never opens one should not carry a renderer for it:

Terminal window
npm install estella-plugin-minigame-services

The API says the host’s constraint out loud rather than hiding it:

import { UIVisual } from 'esengine';
import { miniGameServicesPlugin, Leaderboard } from 'estella-plugin-minigame-services';
addPlugin(miniGameServicesPlugin);
const leaderboard = app.getResource(Leaderboard);
// Your half: write your own row. It is the one cloud operation the game
// itself may do — reading anyone's, including your own, belongs to the context.
leaderboard.submit(score);
// Ask the context to draw. This is a request, not a question: no rows come
// back, no count, no "did it work".
leaderboard.show({ limit: 10, order: 'desc' });
// What it drew, as a texture handle any UIVisual can wear.
world.get(panel, UIVisual).texture = leaderboard.texture;
leaderboard.hide(); // clears the board and stops sampling

Leaderboard.available is false where there is no context — web, native, and any package that declares none. Hide the button rather than open a panel that stays blank.

The project owns the context directory; the package supplies what goes in it:

open-data/index.ts
import 'estella-plugin-minigame-services/open-data';

That is the whole file. The exporter bundles open-data/ separately and names it in game.json; a project without one ships no context, and available says so rather than a board failing on a device.

What arrives is a board with the rows ranked, the player’s own row emphasised, and a friend who has never played left off rather than shown as zero. Style it through show({ style }) — colours, row height, font size, avatars. The canvas is a fixed size and cannot scroll: no pointer or key event reaches that runtime, so limit is how many rows fit, not a page.

Write the file yourself instead of importing the package’s. It runs in the context, so it may not import esengine — the export fails if it does, and so does entering Play, which is better than finding out on a device. Inside, you have a 2D canvas from wx.getSharedCanvas(), wx.getFriendCloudStorage(), and wx.onMessage() carrying whatever show() sent.

Play mode has no host, so the editor stands in for one: it runs your open-data/index.ts against an offscreen canvas and obviously-invented friends, and answers Leaderboard through the same capabilities a device would. The board you lay the panel out against is the one that ships — whoever wrote it — and submit() reaches it, so your own row moves.

What it cannot rehearse is the part that is genuinely the host’s: real friends and the sandbox they live in. Check that on a device before you ship.

What it cannot rehearse is the part that is genuinely the host’s — real friends and the sandbox they live in. Check that on a device before you ship.

A mini-game host signs the player in and hands back a one-time code. That code is not an identity: turning it into one takes your app secret, and an app secret in a client is an app secret anyone can read. So the exchange belongs to your own server, and the engine’s job ends at the code.

import { defineSystem, Res, Identity } from 'esengine';
if (!identity.available) return; // web, native, the editor
// Skip the round trip when the session your server holds is still good.
if (await identity.sessionValid()) return;
const { code } = await identity.login(); // a CODE, not a session
const session = await postToYourServer('/session', { code });

login() rejects — with the host’s own words — when the sign-in fails, and rejects immediately where the platform has none, so a caller that skipped available hears about it instead of awaiting a promise that never settles. The code is short-lived and single-use: cache the session your server returns, never the code.

There is deliberately no local stand-in for this in play mode, unlike ads and the leaderboard. A pretend ad is still a real pause and a pretend board is still the real renderer, so rehearsing them tells you something. A pretend code is a string no server can exchange — rehearsing with it would only rehearse a request that is going to fail. Off-platform, available is false, and a game takes whatever path it takes when there is no account.

Buying inside a mini-game is a permission, not a feature. On WeChat it is Android-only: the call is present on an iPhone and the platform refuses it. So available answers for the device, and a shop asks before it opens rather than finding out when someone taps Buy.

import { defineSystem, Res } from 'esengine';
import { Payment } from 'estella-plugin-minigame-services';
if (!payment.available) return; // iOS, web, native — do not open the shop
try {
await payment.request({ offerId: 'your-offer-id', quantity: 10 });
// The HOST says the purchase completed. Now ask YOUR server what the player
// owns — do not add the coins here.
await refreshInventoryFromYourServer();
} catch (err) {
// The host's own message and code, including "the player changed their mind".
// Those are different UI, and only the host can tell them apart.
if ((err as { code?: number }).code === /* your host's cancel code */ 2) return;
showPurchaseFailed(err);
}

Pass sandbox: true while you build the flow, and zoneId if your game has more than one server.

Two things this service deliberately does not do. It does not interpret the host’s error codes — they differ between vendors, and a mapping invented in the engine is a guess your game would then branch on. And it grants nothing: a purchase the client believes in is a purchase an attacker can claim. The host notifies your server, and your server is what hands out currency; request() resolving is the cue to go and ask it.

There is no local stand-in in play mode, for the same reason sign-in has none — a rehearsed purchase that charges nothing and grants nothing rehearses only the dialog, and the part that goes wrong is behind it.