Host Bridge
The game page runs in your App’s WebView and talks to the App through a small JSBridge. It follows the BAISHUN 1.3.2 JSBridge format that most voice-chat apps already implement, so if you embed other H5 games today you probably have it.
Open the game at its entry URL (see Game catalogue):
https://sandbox.sentosatower.com/games/<gameId>/index.html?op=<operatorId>https://games.sentosatower.com/games/<gameId>/index.html?op=<operatorId>Methods your App provides
Section titled “Methods your App provides”| Method | Called by the page as | Your App should |
|---|---|---|
getConfig |
Android / Flutter: NativeBridge.getConfig(JSON.stringify({ jsCallback }))iOS: webkit.messageHandlers.getConfig.postMessage({ jsCallback }) |
Fetch a fresh launch code, then call window[jsCallback]("<config JSON string>") |
gameLoaded |
NativeBridge.gameLoaded() / messageHandlers.gameLoaded.postMessage({}) |
First frame is ready: hide your loading view |
gameRecharge |
same pattern | Open your top-up page (balance too low, or the player tapped the balance) |
destroy |
same pattern | Close the game and release the WebView |
Optional extensions — ignored silently if you do not implement them. The page passes one JSON string argument:
| Method | Payload | Use |
|---|---|---|
haptic |
{"type":"light" | "success" | "heavy"} |
Vibrate |
roomEvent |
{"type":"bigwin","gameId","name","amount","roundId"} |
Announce a big win in the room |
displayMode |
{"mode":"half" | "full","height"?} |
The player switched half / full screen (e.g. adjust touch pass-through) |
App → page: after a successful top-up call window.walletUpdate({}); the game refreshes the balance from the platform.
getConfig fields
Section titled “getConfig fields”| Field | Required | Description |
|---|---|---|
code |
yes | Launch code: opaque, one-time, valid ≤ 5 minutes, issued by your backend. The platform exchanges it via POST /players/authenticate. |
userId |
recommended | Your player id (display and troubleshooting; identity comes from authenticate) |
roomId |
optional | Current voice-chat room, echoed as room_id on transactions |
language |
recommended | ISO language code (en, ar, zh-CN, …) or BAISHUN numeric code |
gameMode |
optional | 2 half screen, 3 full screen |
safeHeight, safeHeightUnit |
optional | Usable height in half-screen mode; unit css (CSS pixels) or design750 (pixels on a 750-wide design) |
appId, appChannel, gameConfig, gsp |
optional | BAISHUN compatibility fields, accepted and ignored |
{ "code": "lc_7Qm2…", "userId": "84021734", "roomId": "8880085", "language": "ar", "gameMode": 2, "safeHeight": 420, "safeHeightUnit": "css" }Timing requirements
Section titled “Timing requirements”- Bridge before page scripts. If your App injects the bridge late (after the page starts, as Flutter and some Android wrappers do), append
uid=<userId>&time=<unix ms>to the entry URL. With both parameters present the page waits up to 3 s for the bridge to appear instead of falling back to browser mode. - getConfig answer within 8 s. If
NativeBridge.getConfigsynchronously returns a non-empty JSON string, the page uses it. Otherwise it waits for thejsCallbackcall up to 8 s, then shows an entry error. Budget about 5 s for fetching the launch code from your backend. - No stale scripts. Game scripts are served with
Cache-Control: no-cacheand versioned URLs; do not force-cache the WebView. - Call
getConfigfresh for every game open: launch codes are single-use.
App snippets
Section titled “App snippets”class StarGameBridge(private val activity: Activity, private val webView: WebView) {
@JavascriptInterface fun getConfig(params: String?): String { val callback = params?.let { JSONObject(it).optString("jsCallback") }.orEmpty() // Ask your backend for a one-time launch code (off the UI thread). launchCodes.issue(currentUser.id) { code -> val config = JSONObject() .put("code", code) .put("userId", currentUser.id) .put("roomId", currentRoom.id) .put("language", Locale.getDefault().language) .put("gameMode", 2) .put("safeHeight", halfScreenHeightCssPx) .put("safeHeightUnit", "css") val args = JSONArray().put(callback).put(config.toString()) webView.post { webView.evaluateJavascript("(function(a){window[a[0]](a[1]);})($args)", null) } } return "" // empty: the page waits for the callback }
@JavascriptInterface fun gameLoaded() = activity.runOnUiThread { hideLoading() } @JavascriptInterface fun gameRecharge() = activity.runOnUiThread { openTopUp() } @JavascriptInterface fun destroy() = activity.runOnUiThread { closeGame() } @JavascriptInterface fun haptic(payload: String) { /* optional */ } @JavascriptInterface fun displayMode(payload: String) { /* optional */ }}
webView.settings.javaScriptEnabled = truewebView.settings.domStorageEnabled = truewebView.addJavascriptInterface(StarGameBridge(activity, webView), "NativeBridge")webView.loadUrl("https://sandbox.sentosatower.com/games/lantern/index.html?op=$operatorId")final class StarGameHost: NSObject, WKScriptMessageHandler { weak var webView: WKWebView?
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { switch message.name { case "getConfig": guard let body = message.body as? [String: Any], let callback = body["jsCallback"] as? String else { return } LaunchCodes.issue(for: Session.userId) { code in // one-time launch code from your backend let config: [String: Any] = ["code": code, "userId": Session.userId, "roomId": Room.current.id, "language": Locale.current.language.languageCode?.identifier ?? "en", "gameMode": 2, "safeHeight": 420, "safeHeightUnit": "css"] let json = String(data: try! JSONSerialization.data(withJSONObject: config), encoding: .utf8)! let args = String(data: try! JSONSerialization.data(withJSONObject: [callback, json]), encoding: .utf8)! DispatchQueue.main.async { self.webView?.evaluateJavaScript("(function(a){window[a[0]](a[1]);})(\(args))") } } case "gameLoaded": hideLoading() case "gameRecharge": openTopUp() case "destroy": closeGame() default: break // haptic, roomEvent, displayMode are optional } }}
let host = StarGameHost()let contentController = WKUserContentController()for name in ["getConfig", "gameLoaded", "gameRecharge", "destroy", "haptic", "roomEvent", "displayMode"] { contentController.add(host, name: name)}let configuration = WKWebViewConfiguration()configuration.userContentController = contentControllerlet webView = WKWebView(frame: .zero, configuration: configuration)host.webView = webViewwebView.load(URLRequest(url: URL(string: "https://sandbox.sentosatower.com/games/lantern/index.html?op=\(operatorId)")!))// A JavaScriptChannel is one-way and async, so inject a small NativeBridge shim that forwards to it.const bridgeShim = r'''(function () { if (window.NativeBridge) return; function send(method, params) { StarGameHost.postMessage(JSON.stringify({ method: method, params: params || "" })); } window.NativeBridge = { getConfig: function (p) { send("getConfig", p); return ""; }, gameLoaded: function () { send("gameLoaded"); }, gameRecharge: function () { send("gameRecharge"); }, destroy: function () { send("destroy"); }, haptic: function (p) { send("haptic", p); }, roomEvent: function (p) { send("roomEvent", p); }, displayMode: function (p) { send("displayMode", p); } };})();''';
late final WebViewController controller;
void openGame(String gameId) { final uid = session.userId; final url = 'https://sandbox.sentosatower.com/games/$gameId/index.html' '?op=$operatorId&uid=$uid&time=${DateTime.now().millisecondsSinceEpoch}'; // page waits up to 3 s for the bridge controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel('StarGameHost', onMessageReceived: (m) => onHostMessage(m.message)) ..setNavigationDelegate(NavigationDelegate(onPageStarted: (_) => controller.runJavaScript(bridgeShim))) ..loadRequest(Uri.parse(url));}
Future<void> onHostMessage(String raw) async { final msg = jsonDecode(raw) as Map<String, dynamic>; switch (msg['method']) { case 'getConfig': final callback = (jsonDecode(msg['params'] as String) as Map<String, dynamic>)['jsCallback'] as String; final code = await launchCodes.issue(session.userId); // one-time launch code from your backend final config = jsonEncode({'code': code, 'userId': session.userId, 'roomId': room.id, 'language': 'en', 'gameMode': 2, 'safeHeight': 420, 'safeHeightUnit': 'css'}); await controller.runJavaScript('(function(a){window[a[0]](a[1]);})(${jsonEncode([callback, config])});'); case 'gameLoaded': hideLoading(); case 'gameRecharge': openTopUp(); case 'destroy': closeGame(); }}Self-test in a desktop browser
Section titled “Self-test in a desktop browser”You can check your launch codes without the App. Open the entry URL with &uid=1&time=1 added, and inject this before the page scripts run (for example with a userscript manager set to run at document start):
window.NativeBridge = { getConfig(p) { const cb = JSON.parse(p).jsCallback; setTimeout(() => window[cb](JSON.stringify({ code: "<launch code>", userId: "<player id>", gameMode: 2, language: "en" })), 20); return ""; }, gameLoaded() { console.log("host: gameLoaded"); }, gameRecharge() { console.log("host: gameRecharge"); }, destroy() { console.log("host: destroy"); },};Or automate it with Playwright:
import { chromium } from "playwright";
const browser = await chromium.launch();const page = await browser.newPage({ viewport: { width: 390, height: 844 } });page.on("console", (m) => console.log(m.text()));await page.addInitScript((code) => { window.NativeBridge = { getConfig(p) { const cb = JSON.parse(p).jsCallback; setTimeout(() => window[cb](JSON.stringify({ code, userId: "self-test", gameMode: 2, language: "en" })), 20); return ""; }, gameLoaded() { console.log("host: gameLoaded"); }, gameRecharge() {}, destroy() {}, };}, process.env.LAUNCH_CODE);await page.goto(`https://sandbox.sentosatower.com/games/lantern/index.html?op=${process.env.OPERATOR_ID}&uid=1&time=1`);await page.waitForTimeout(5000);await page.screenshot({ path: "lantern.png" });await browser.close();Expected: the betting screen appears within about 3 seconds, host: gameLoaded is logged, and your wallet receives one POST /players/authenticate for the launch code.