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:

  1. Export a web build to a folder with index.html at the top.
  2. Add the SDK: copy twiddleplay-sdk.js into that folder and add the two script tags.
  3. Call the SDK from your game: ready(), ads at the right moments, and pause / mute handling.
  4. 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:

index.htmlHTML
<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.

scenes/Boot.jsJavaScript
this.load.setPath('assets/');          // relative: good
this.load.image('hero', 'hero.png');
// this.load.setPath('/assets/');      // absolute: breaks on Twiddle Play

Scale to any phone:

main.jsJavaScript
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:

main.jsJavaScript
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());
scenes/LevelClear.jsJavaScript
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.

main.jsJavaScript
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.js with Vite or webpack. Don't import it from a CDN.
  • PixiJS has no audio of its own. If you use @pixi/sound, mute it on mute (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#

main.jsJavaScript
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 copy three.module.js and the addons you use into your folder.
  • Decoders. DRACOLoader and KTX2Loader load decoder files from a path you set. Copy those files into your zip and use a relative path, such as setDecoderPath('draco/').
  • File types. .glb, .gltf, .bin, .wasm and common image types are allowed. .hdr, .exr, .ktx2 and .basis are 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.

main.jsJavaScript
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 (or kaboom.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 and volume(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-clicking index.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:
Head IncludeHTML
<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.

twiddle_play.gd (autoload)GDScript
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 JavaScriptBridge docs 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 .gz and .br files aren't allowed in the zip.
  • If you need compression, choose Gzip or Brotli and tick Decompression Fallback. Unity then names the files .unityweb and decompresses them in JavaScript. .unityweb is allowed.
  • Leave multithreading off. The headers it needs aren't sent.
  • Your build folder then holds index.html, Build/ (.loader.js, .framework.js, .data, .wasm or .unityweb files) and TemplateData/. 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:

index.html (inside createUnityInstance(...).then)JavaScript
.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');
    });
  },
});
  • SendMessage passes a string (or a number) to C#. Strings are the simplest, as above.
  • Don't use Application.ExternalEval or Application.ExternalCall. They're obsolete; use a .jslib as 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:

Script action: on 'Next level' tappedJavaScript
await globalThis.TwiddlePlay.showInterstitial();
runtime.callFunction("StartNextLevel");
Script action: on 'Watch ad' tappedJavaScript
const r = await globalThis.TwiddlePlay.showRewarded();
runtime.globalVars.Rewarded = r.rewarded ? 1 : 0;
runtime.callFunction("AfterRewarded");
Script action: On start of layout (first layout only)JavaScript
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:

JavaScript code event: when 'Watch ad' is clickedJavaScript
TwiddlePlay.showRewarded().then(function (r) {
  runtimeScene.getVariables().get("Rewarded").setNumber(r.rewarded ? 1 : 0);
});
JavaScript code event: at the beginning of the first sceneJavaScript
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: './':

vite.config.jsJavaScript
import { defineConfig } from 'vite';

export default defineConfig({
  base: './',            // relative asset paths: required
  build: { outDir: 'dist', assetsInlineLimit: 0 },
});

Then:

  1. Put twiddleplay-sdk.js in public/, so Vite copies it next to the built index.html.
  2. Add the two tags to the <head> of your source index.html. Vite prints a note that it can't bundle them (they aren't type="module"). That's expected: it leaves them as they are.
  3. Run npm run build and zip the contents of dist/.

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:

webpack.config.jsJavaScript
module.exports = {
  output: {
    path: __dirname + '/dist',
    publicPath: '',        // or 'auto' (webpack 5's default); never '/'
  },
  // ...your loaders, plus HtmlWebpackPlugin for index.html
};
  • Copy twiddleplay-sdk.js into dist/ (for example with copy-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.html at 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 SharedArrayBuffer or pre-compressed (.gz, .br) files? Turn those options off.
  • Can you run your own JavaScript? Then you can call TwiddlePlay directly, as in the examples above.