Skip to content

Mini-Game Platforms

WeChat MiniGame is not a special case in Estella — it is one profile of a platform family. Mini-game hosts (WeChat’s wx, Douyin’s tt, and the rest) expose a near-identical API: no DOM, a packaged filesystem, subpackages, touch input, key-value storage. Estella implements that model once and describes each vendor as data.

This guide is for bringing up a vendor Estella does not ship. If you are shipping to WeChat, you want WeChat MiniGame instead.

A profile is three facts and, at most, one method:

import { installMiniGamePlatform } from 'esengine/minigame';
declare const myHost: any; // the host global, e.g. `tt`
installMiniGamePlatform({
id: 'myvendor',
hostLabel: 'MyVendor',
get global() { return myHost; },
});

That is a complete platform. Filesystem, fetch, canvas, image decode, touch and key input, storage, subpackages, memory-pressure signals, device pixel ratio and language all come from the normalized host global. So do audio (createInnerAudioContext) and sockets (connectSocket). Video uses the engine’s own wasm MPEG-1 decoder, which is portable to every host.

Read global through a getter, as above: the host global is ambient at runtime, and a getter means importing your profile in a bundler or a test never touches an undefined binding.

WASM instantiation. WeChat routes it through WXWebAssembly.instantiate(path) rather than the standard WebAssembly, so its profile overrides one method:

{
id: 'wechat',
hostLabel: 'WeChat',
get global() { return wx as unknown as MiniGameGlobal; },
instantiateWasm(pathOrBuffer, imports) { /* WXWebAssembly */ },
}

If your host uses standard WebAssembly, omit it — the family reads the binary through the packaged filesystem and instantiates it.

createAudioBackend, createVideoBackend and createSocket are optional the same way. Override one only where the host genuinely differs; the defaults are MiniGameAudioBackend, the wasm video decoder, and MiniGameSocket, all exported from esengine/minigame so you can wrap rather than rewrite.

The optional methods are not only for host differences — they are the seam for replacing an implementation you don’t want. Writing your own video decoding, for example, is one method:

import type { MiniGameProfile, PlatformVideoBackend, VideoBackendContext } from 'esengine/minigame';
class SoftwareVideoBackend implements PlatformVideoBackend {
readonly name = 'my-software-decoder';
constructor(private ctx: VideoBackendContext) {}
createStream(url: string, options) { /* your decode → VideoStreamHandle */ }
dispose() {}
}
export default {
id: 'myvendor',
hostLabel: 'MyVendor',
get global() { return myHost; },
createVideoBackend: (ctx) => new SoftwareVideoBackend(ctx),
} satisfies MiniGameProfile;

VideoPlugin asks the platform for a backend once at build; whatever you return is what every VideoPlayer in the game drives. The contract to satisfy is PlatformVideoBackendcreateStream(url, options) returning a VideoStreamHandle whose pump(module) uploads the current frame to textureHandle once per frame. Audio (PlatformAudioBackend) and sockets (PlatformSocket) work the same way.

To keep most of a host and change one thing, spread its profile:

import { wechatProfile } from 'esengine/wechat';
export default { ...wechatProfile, createVideoBackend: (ctx) => new SoftwareVideoBackend(ctx) };

Your global has to satisfy MiniGameGlobal. Required:

Member Used for
createCanvas() display surface (first call) + offscreen 2D
createImage() image decode
getFileSystemManager() readFileSync / readFile / access / accessSync / writeFile
request(opts) the fetch polyfill
createInnerAudioContext() audio playback
connectSocket(opts) GameSocket / replication
getSystemInfoSync() viewport size, pixel ratio, language
onTouchStart/Move/End + off* pointer input
getStorageSync / setStorageSync / removeStorageSync / getStorageInfoSync save data, hot-update bookkeeping

Optional — the adapter probes for these and degrades gracefully when a host lacks one: onTouchCancel/offTouchCancel, onKeyDown/onKeyUp (+ off*), loadSubpackage, onMemoryWarning/offMemoryWarning, onShow/onHide.

The shipped package’s entry calls the family runtime:

// game.js — the host runs this
const engineFactory = require('./wasm/esengine.js');
const bundle = require('./game-bundle.js');
bundle.boot(engineFactory, { /* side module factories */ });
// bundled with your game
import { installMiniGamePlatform, initMiniGameRuntime } from 'esengine/minigame';
import { myProfile } from './myProfile';
export function boot(engineFactory, sideModuleFactories) {
installMiniGamePlatform(myProfile);
return initMiniGameRuntime({
engineFactory,
engineWasmPath: 'wasm/esengine.wasm',
sceneNames: ['Main'],
firstScene: 'Main',
sideModuleFactories,
});
}

initMiniGameRuntime takes the display canvas from the adapter, registers its GL context with the engine module, loads the addressable manifest, and runs the app — the same sequence WeChat boots through. initWeChatRuntime is this function with WeChat’s staged wasm name filled in, and nothing else.

In Package Project, hover the Project group in the platform list and hit +. Give it an id and a name, and the editor writes both halves of the vendor — already joined — then selects the new platform so you can package it as soon as you have described your host.

The files it writes are ordinary project files; nothing is registered anywhere else. This is the packaging half:

.esengine/platforms/acme-play.mjs
export default {
id: 'acme-play',
label: 'ACME Play',
blurb: 'ACME mini-game package.',
defaultOut: 'dist-acme',
emitConfigFiles(ctx) {
return [{
file: 'game.json',
content: JSON.stringify({
orientation: ctx.orientation,
appName: ctx.title,
...(ctx.subPackages.length > 0 ? { subPackages: ctx.subPackages } : {}),
}, null, 2) + '\n',
}];
},
};

That is the whole file. Three facts and one emitter — the rest defaults to a standard mini-game host: the vendor-neutral SDK entry (index.minigame.js), initMiniGameRuntime, the standard WebAssembly engine glue, and the generic CommonJS game.js. Everything else — cook, manifest, scene transform, side-module scan, bundling, runtime copy — is the same pipeline WeChat uses.

The file above is the packaging half (scaffolded for you by +). The runtime half is the MiniGameProfile from the first part of this guide — the host global, and any capability you replace. Point at it with runtimeProfile and the generated entry installs it before booting:

.esengine/platforms/acme-play.mjs
export default {
id: 'acme-play',
label: 'ACME Play',
runtimeProfile: 'src/acme-runtime.ts', // ← the other half
emitConfigFiles(ctx) { /* … */ },
};
// src/acme-runtime.ts — default-exports the runtime MiniGameProfile
export default {
id: 'acme-play',
hostLabel: 'ACME Play',
get global() { return acme; },
createVideoBackend: (ctx) => new SoftwareVideoBackend(ctx),
};

This link matters because esengine/minigame installs no platform until the game names a host. Without it the package builds cleanly and then throws on the device. Omit runtimeProfile only if your game calls installMiniGamePlatform itself; the editor checks the path exists and refuses to call the platform ready otherwise.

The id must be lowercase letters/digits/dashes and must not shadow a built-in. It is also the cook’s Import Settings key, so per-texture compression overrides can target your platform by name.

Everything you can override, and what the pipeline uses it for:

Field What it says
id vendor identity — also the Import Settings key the cook reads per texture
sdkEntryFile SDK dist entry esengine is aliased to
runtimeInit the boot function the generated entry imports
engineGlueCandidates engine glue filenames to look for, in preference order
esTarget syntax floor for the bundle and the down-leveled glue
wasmBuildHint / sideModuleBuildTargets the build -t … names in error guidance
nativeSuffixes / binRestageExts the packer’s suffix policy
subpackageDir where lazy groups stage
emitConfigFiles / emitEntry the vendor’s config files and game.js
wasmDir project-relative engine runtime dir (default: the editor’s web runtime)
runtimeProfile project-relative module default-exporting the runtime MiniGameProfile

Only if it cannot load the web engine’s wasm. WeChat needs one (-t wechat, ES_BUILD_WXGAME) because of WXWebAssembly and because it has no WebGPU. A host with standard WebAssembly and WebGL2 can ship the web build’s glue — engineGlueCandidates already falls back to esengine.js for exactly this case.