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.
Rewarded ads
Section titled “Rewarded ads”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.
Where ads exist
Section titled “Where ads exist”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 resolvescompleted: true, with the real pause/audio ceremony running. Your revive flow rehearses at your desk. - Web and native builds — no ad system,
availableisfalse. A native shell that integrates a mediation SDK installs it through the same door the editor uses:Ads.setProvider(...).
Sharing
Section titled “Sharing”Sharing and in-game purchase ship as a package rather than inside the engine — most games open neither:
npm install estella-plugin-minigame-servicesimport { 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 cardshare.share({ title: 'Join my game' }); // or a one-off cardWhoever 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.
Friends leaderboard
Section titled “Friends leaderboard”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:
npm install estella-plugin-minigame-servicesThe 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 samplingLeaderboard.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 other half
Section titled “The other half”The project owns the context directory; the package supplies what goes in it:
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.
Your own board
Section titled “Your own board”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.
At your desk
Section titled “At your desk”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.
Signing in
Section titled “Signing in”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 sessionconst 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.
In-game purchase
Section titled “In-game purchase”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.