Build7 min read
Using game engines
Export settings and SDK setup for Phaser, PixiJS, Three.js, KAPLAY, Godot, Unity, Construct 3, GDevelop, and Vite or webpack projects.
On this page12 sections
Any engine that exports to HTML5 can ship on Twiddle Play. The steps are always the same:
- Export a web build to a folder with
index.htmlat the top. - Add the SDK: copy
twiddleplay-sdk.jsinto that folder and add the two script tags. - Call the SDK from your game:
ready(), ads at the right moments, andpause/mutehandling. - Zip the folder's contents and upload.
This page covers the engine-specific parts. The SDK itself is explained in the SDK reference.
The SDK tags, for every engine#
Wherever an engine lets you edit the exported index.html, put these two lines before the engine's own scripts, and copy twiddleplay-sdk.js next to index.html:
<script src="/sdk/v1/twiddleplay.js"></script>
<script src="twiddleplay-sdk.js"></script>After that, window.TwiddlePlay exists before your game code runs. The SDK reference explains why there are two.
Phaser#
Phaser games are plain JavaScript, so the SDK drops straight in. These examples use Phaser 3.
Export: if you use a bundler template (Vite, webpack), see Vite and webpack below. If you load Phaser with a <script> tag, download phaser.min.js into your folder instead of linking a CDN.
Paths: load assets with relative paths.
this.load.setPath('assets/'); // relative: good
this.load.image('hero', 'hero.png');
// this.load.setPath('/assets/'); // absolute: breaks on Twiddle PlayScale to any phone:
const game = new Phaser.Game({
type: Phaser.AUTO,
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, width: 720, height: 1280 },
scene: [Boot, Play],
});Wire up the SDK:
TwiddlePlay.on('pause', () => game.pause());
TwiddlePlay.on('resume', () => game.resume());
TwiddlePlay.on('mute', () => { game.sound.mute = true; });
TwiddlePlay.on('unmute', () => { game.sound.mute = playerMutedSound; });
game.events.once('ready', () => TwiddlePlay.ready());this.nextButton.on('pointerup', async () => {
await TwiddlePlay.showInterstitial();
this.scene.start('Play', { level: this.level + 1 });
});PixiJS#
PixiJS draws; your code runs the loop. These examples use PixiJS v8.
import { Application, Assets, Sprite } from 'pixi.js';
const app = new Application();
await app.init({ resizeTo: window, resolution: Math.min(devicePixelRatio, 2), autoDensity: true });
document.body.appendChild(app.canvas);
const tex = await Assets.load('assets/hero.png'); // relative path
app.stage.addChild(new Sprite(tex));
TwiddlePlay.on('pause', () => app.ticker.stop());
TwiddlePlay.on('resume', () => app.ticker.start());
TwiddlePlay.ready();- Bundle
pixi.jswith Vite or webpack. Don't import it from a CDN. - PixiJS has no audio of its own. If you use
@pixi/sound, mute it onmute(check the @pixi/sound docs for the mute-all call in your version), or route your own Web Audio through one gain node you can set to 0.
Three.js#
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // phones have DPR 3; 2 is plenty
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
new GLTFLoader().load('models/ship.glb', (gltf) => scene.add(gltf.scene)); // relative path
const listener = new THREE.AudioListener();
camera.add(listener);
TwiddlePlay.on('pause', () => renderer.setAnimationLoop(null));
TwiddlePlay.on('resume', () => { clock.getDelta(); renderer.setAnimationLoop(loop); });
TwiddlePlay.on('mute', () => listener.setMasterVolume(0));
TwiddlePlay.on('unmute', () => listener.setMasterVolume(playerMuted ? 0 : 1));
renderer.setAnimationLoop(loop);
TwiddlePlay.ready();Things that trip people up:
- Import maps that point at a CDN (
unpkg,jsdelivr) don't work offline. Bundle with Vite, or copythree.module.jsand the addons you use into your folder. - Decoders.
DRACOLoaderandKTX2Loaderload decoder files from a path you set. Copy those files into your zip and use a relative path, such assetDecoderPath('draco/'). - File types.
.glb,.gltf,.bin,.wasmand common image types are allowed..hdr,.exr,.ktx2and.basisare not on the allowed list, so the upload is rejected if they're in your zip. Use a PNG or JPG environment map instead. - Size. Keep models and textures small. Phones have far less memory than your laptop, and the app closes a game whose page runs out of memory.
Kaboom and KAPLAY#
KAPLAY is the community continuation of Kaboom.js. Both work the same way here.
kaplay({ width: 720, height: 1280, letterbox: true });
loadRoot('./'); // relative root
loadSprite('bean', 'sprites/bean.png');
TwiddlePlay.on('pause', () => { debug.paused = true; });
TwiddlePlay.on('resume', () => { debug.paused = false; });
TwiddlePlay.ready();
scene('win', () => {
add([text('Level clear!'), pos(center()), anchor('center')]);
onClick(async () => {
await TwiddlePlay.showInterstitial();
go('game', { level: level + 1 });
});
});- Download
kaplay.js(orkaboom.js) into your folder, or bundle it. Don't load it from a CDN. - For sound, the global volume call is
setVolume(0)in KAPLAY andvolume(0)in Kaboom 3000. Check your version's docs if neither exists. - Assets load with
fetch, so test from a local server, not by double-clickingindex.html.
Godot 4 (Web export)#
Export settings (Project → Export → Add… → Web):
- Export Path: a new empty folder, with the file name
index.html. - Thread Support: off. Twiddle Play doesn't send the headers threaded builds need. (Godot 4.3 and later can export without threads. Older Godot 4 versions always need them, so use 4.3 or newer.)
- Head Include: paste the two SDK tags:
<script src="/sdk/v1/twiddleplay.js"></script>
<script src="twiddleplay-sdk.js"></script>After exporting, copy twiddleplay-sdk.js into the export folder. The folder holds index.html, index.js, index.wasm, index.pck and a few more .js and .png files. All of those types are allowed.
Call the SDK from GDScript with JavaScriptBridge. Keep the callbacks in member variables, or Godot frees them before the promise answers.
extends Node
signal rewarded_done(ok: bool)
var _tp: JavaScriptObject
var _cb_rewarded: JavaScriptObject
var _cb_inter: JavaScriptObject
var _cb_pause: JavaScriptObject
var _cb_resume: JavaScriptObject
var _cb_mute: JavaScriptObject
var _cb_unmute: JavaScriptObject
func _ready() -> void:
if not OS.has_feature("web"):
return
_tp = JavaScriptBridge.get_interface("TwiddlePlay")
if _tp == null:
return
_cb_rewarded = JavaScriptBridge.create_callback(_on_rewarded)
_cb_inter = JavaScriptBridge.create_callback(_on_interstitial)
_cb_pause = JavaScriptBridge.create_callback(func(_a): get_tree().paused = true)
_cb_resume = JavaScriptBridge.create_callback(func(_a): get_tree().paused = false)
_cb_mute = JavaScriptBridge.create_callback(func(_a): AudioServer.set_bus_mute(0, true))
_cb_unmute = JavaScriptBridge.create_callback(func(_a): AudioServer.set_bus_mute(0, false))
_tp.on("pause", _cb_pause)
_tp.on("resume", _cb_resume)
_tp.on("mute", _cb_mute)
_tp.on("unmute", _cb_unmute)
_tp.ready()
func show_interstitial() -> void:
if _tp:
_tp.showInterstitial().then(_cb_inter)
func show_rewarded() -> void:
if _tp:
_tp.showRewarded().then(_cb_rewarded)
else:
rewarded_done.emit(false)
func _on_interstitial(_args) -> void:
pass # shown or not, the game continues
func _on_rewarded(args) -> void:
rewarded_done.emit(bool(args[0].rewarded))- Nodes that must keep running while the tree is paused (like your pause menu) need Process → Mode: Always.
- The Godot web engine alone is tens of megabytes before your content. Check the zip size after exporting.
- Check Godot's "Exporting for the Web" and
JavaScriptBridgedocs for your exact version.
Unity (WebGL)#
Unity works, but it's the heaviest option. Even an empty WebGL build is many megabytes, and phones have little memory. Keep scenes and textures small and test on a real mid-range Android phone.
Build settings (File → Build Settings → WebGL, called Build Profiles in Unity 6; then Player Settings → Publishing Settings):
- Compression Format: Disabled. This is the simplest choice. Twiddle Play serves files as-is, without
Content-Encoding, and.gzand.brfiles aren't allowed in the zip. - If you need compression, choose Gzip or Brotli and tick Decompression Fallback. Unity then names the files
.unityweband decompresses them in JavaScript..unitywebis allowed. - Leave multithreading off. The headers it needs aren't sent.
- Your build folder then holds
index.html,Build/(.loader.js,.framework.js,.data,.wasmor.unitywebfiles) andTemplateData/. Zip the contents of that folder.
Add the SDK: put the two tags in the <head> of your WebGL template's index.html (or of the built index.html), and copy twiddleplay-sdk.js next to it.
Then find the template's createUnityInstance(...) call. In its .then(...), keep a reference to the instance so JavaScript can call back into C#, and wire up pause:
.then(function (unityInstance) {
window.unityInstance = unityInstance;
TwiddlePlay.on('pause', function () { unityInstance.SendMessage('TwiddlePlay', 'OnPause'); });
TwiddlePlay.on('resume', function () { unityInstance.SendMessage('TwiddlePlay', 'OnResume'); });
TwiddlePlay.ready();
// ...keep whatever the template already does here (like hiding the loading bar)
})Call the SDK from C# through a .jslib plugin:
mergeInto(LibraryManager.library, {
TP_ShowInterstitial: function () {
TwiddlePlay.showInterstitial().then(function () {
window.unityInstance.SendMessage('TwiddlePlay', 'OnInterstitialDone');
});
},
TP_ShowRewarded: function () {
TwiddlePlay.showRewarded().then(function (r) {
window.unityInstance.SendMessage('TwiddlePlay', 'OnRewardedDone', r.rewarded ? 'yes' : 'no');
});
},
});using System.Runtime.InteropServices;
using UnityEngine;
// Put this on a GameObject named "TwiddlePlay" in your first scene.
public class TwiddlePlay : MonoBehaviour
{
[DllImport("__Internal")] private static extern void TP_ShowInterstitial();
[DllImport("__Internal")] private static extern void TP_ShowRewarded();
public System.Action<bool> RewardedDone;
void Awake() { DontDestroyOnLoad(gameObject); }
public void ShowInterstitial() {
#if UNITY_WEBGL && !UNITY_EDITOR
TP_ShowInterstitial();
#endif
}
public void ShowRewarded() {
#if UNITY_WEBGL && !UNITY_EDITOR
TP_ShowRewarded();
#else
RewardedDone?.Invoke(false);
#endif
}
void OnInterstitialDone() { }
void OnRewardedDone(string ok) { RewardedDone?.Invoke(ok == "yes"); }
void OnPause() { Time.timeScale = 0; AudioListener.pause = true; }
void OnResume() { Time.timeScale = 1; AudioListener.pause = false; }
}SendMessagepasses a string (or a number) to C#. Strings are the simplest, as above.- Don't use
Application.ExternalEvalorApplication.ExternalCall. They're obsolete; use a.jslibas shown. - Check Unity's "Interaction with browser scripting" docs for your Unity version.
Construct 3#
Export: Menu → Project → Export → Web (HTML5). Unzip the export Construct gives you, and work in that folder.
Add the SDK: open the exported index.html, add the two tags in <head>, and copy twiddleplay-sdk.js next to it. Construct overwrites index.html on every export, so redo this each time.
Run scripts on the main thread: in Project Properties → Advanced, set Use worker to No. In worker mode your scripts can't see window.TwiddlePlay. (Check Construct's manual if your version names it differently.)
Call the SDK from events with a script action. Set a global variable or call an event function to bring the answer back into your event sheet:
await globalThis.TwiddlePlay.showInterstitial();
runtime.callFunction("StartNextLevel");const r = await globalThis.TwiddlePlay.showRewarded();
runtime.globalVars.Rewarded = r.rewarded ? 1 : 0;
runtime.callFunction("AfterRewarded");globalThis.TwiddlePlay.on("pause", () => runtime.callFunction("OnPause"));
globalThis.TwiddlePlay.on("resume", () => runtime.callFunction("OnResume"));
globalThis.TwiddlePlay.ready();In the OnPause function, set the time scale to 0 and set Audio to silent. Undo both in OnResume.
GDevelop#
Export: File → Export → Web → export to a local folder (the manual-upload option, not GDevelop's own hosting).
Add the SDK: open the exported index.html, add the two tags at the top of <head>, and copy twiddleplay-sdk.js next to it. Redo this after every export.
Call the SDK from a JavaScript code event:
TwiddlePlay.showRewarded().then(function (r) {
runtimeScene.getVariables().get("Rewarded").setNumber(r.rewarded ? 1 : 0);
});TwiddlePlay.on("pause", function () { gdjs.evtTools.runtimeScene.setTimeScale(runtimeScene, 0); });
TwiddlePlay.on("resume", function () { gdjs.evtTools.runtimeScene.setTimeScale(runtimeScene, 1); });
TwiddlePlay.ready();Then check the Rewarded variable in a normal event to give the reward. GDevelop's JavaScript API changes between versions: check its docs for the time-scale and global-volume calls in yours. A safe alternative is to set a scene variable in the callback (like Paused = 1) and do the pausing and muting with normal events.
Vite#
Vite builds use absolute paths (/assets/...) by default. That breaks on Twiddle Play, where your game lives in a sub-folder. Set base: './':
import { defineConfig } from 'vite';
export default defineConfig({
base: './', // relative asset paths: required
build: { outDir: 'dist', assetsInlineLimit: 0 },
});Then:
- Put
twiddleplay-sdk.jsinpublic/, so Vite copies it next to the builtindex.html. - Add the two tags to the
<head>of your sourceindex.html. Vite prints a note that it can't bundle them (they aren'ttype="module"). That's expected: it leaves them as they are. - Run
npm run buildand zip the contents ofdist/.
ES modules work in the app and in the preview. They don't work when you double-click index.html (file://), so test with npx vite preview.
webpack#
Make the output paths relative:
module.exports = {
output: {
path: __dirname + '/dist',
publicPath: '', // or 'auto' (webpack 5's default); never '/'
},
// ...your loaders, plus HtmlWebpackPlugin for index.html
};- Copy
twiddleplay-sdk.jsintodist/(for example withcopy-webpack-plugin) and put the two tags in your HTML template. - Zip the contents of
dist/.
Any other engine#
If your engine isn't listed, check these in its web export:
- Does it make a folder with
index.htmlat the top? (If it makes one folder that contains everything, that's fine too: a single top folder is stripped for you.) - Are all paths relative? Search the exported files for
"/to find absolute paths. - Does it load anything from the internet (fonts, scripts, analytics)? Remove it, or download the file into your folder.
- Does it need
SharedArrayBufferor pre-compressed (.gz,.br) files? Turn those options off. - Can you run your own JavaScript? Then you can call
TwiddlePlaydirectly, as in the examples above.