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. The editor carries it, so there is
nothing to install: import it, in play and in every export. (A copy the project
installs into its own node_modules takes precedence over the editor’s.)
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 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.
Recording a clip
Section titled “Recording a clip”A short clip of a run is how a mini-game spreads on Douyin, and WeChat can
share one too. Recorder ships in the same package as Share:
import { Recorder } from 'estella-plugin-minigame-services';
// When the run starts (recorder is Res(Recorder)):if (recorder.available) await recorder.start();
// When something worth keeping just happened — a kill, a near miss:recorder.highlight(3, 2); // keep 3s before now and 2s after
// When the run ends:await recorder.stop();
// In the "share my run" button's tap handler — both hosts refuse a share// the player did not tap for:if (recorder.canShare) await recorder.share({ title: 'Look at this run' });With highlights, the shared clip is the highlights, in order; without, it is
the whole recording. Every call resolves when the host says the thing
happened, and rejects with the host’s own reason (and code, where it gave
one). A recording the host ends with an error is heard through
recorder.onFailure(...), and state is back to 'idle'.
| Douyin | Editor / web | ||
|---|---|---|---|
Length (maxSeconds) |
5 – 7200 s | 4 – 300 s | any |
What stop() gives |
the duration; WeChat keeps the video | a temp file (path) |
a WebM preview (blob) |
| Shared clip | at most 60 s of highlights, at least 2 s | the highlights, merged | cannot share |
maxSeconds defaults to 300 and is kept inside the host’s range. Only the
game canvas is recorded — an ad or a host dialog on top of it is not.
In the editor’s play mode the recording is real: stop() hands back the
canvas as WebM, so a record button can be built and tried at your desk.
canShare is false there, so the share button hides itself exactly as it
would on a host that cannot share. The record-share example is all of this in
one screen.
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 in the same package as sharing (which the editor carries — nothing to install), rather than in the engine, because a game that never opens one should not carry a renderer for it.
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 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.