Direct Endpoint
Embed games with a plain iframe pointed at /play, or load them in a native
app's WebView, no SDK required. You build the game URL and listen for the game's
messages yourself. This is the same
endpoint the JavaScript SDK loads, so the games, the loading
screen, and the events are identical; this page describes what the SDK does for
you so you can do it by hand.
Choose this when you can't load a third-party script (a CMS embed block, a strict Content Security Policy, server-rendered markup), when you want full control over the iframe, or when you're building a native app (see Native apps). Otherwise, on the web, the SDK is less work.
1. Build the game URL
There are two ways to address a game:
https://staging.play.lilsnack.co/play/{PARTNER_KEY}/{GAME_ID}
https://staging.play.lilsnack.co/play/{PARTNER_KEY}/game/{GAME_TYPE}
By game ID loads one specific game. Any game in the library can be loaded this way, including mini snacks. This is the URL the SDK builds.
By game type loads whatever game of that format is on your schedule today. The URL never changes, so you can embed it once and it serves a new game every day. It's available for our daily puzzle formats. If nothing of that type is scheduled today, the frame shows a "game unavailable" card rather than an error.
Your Lil Snack contact will share the game IDs and game types enabled for your partner key.
Query parameters
| Parameter | Required | Description |
|---|---|---|
aid | Recommended | A stable anonymous ID for this browser. Generate a random UUID once, store it in your page's own storage, and send the same value on every load. Without it, results can't be saved for players you haven't identified, even though the game plays normally. |
uid | No | Your own ID for a signed-in player. See Identity and Persistence. |
idt | No | A signed identity token, for strong-identity integrations. See Identity and Persistence. |
instanceId | No | Any string. It's echoed back on every message, which helps when you embed several games on one page. |
allowAnalytics | No | Set to false to opt the player out of Lil Snack's product analytics. Events sent to your page are not affected. |
share | No | Enables the in-game Share button on games that support it. See Share. |
const LIL_SNACK_HOST = "https://staging.play.lilsnack.co";
function getAnonymousId() {
let id = localStorage.getItem("lil-snack-aid");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("lil-snack-aid", id);
}
return id;
}
function buildGameUrl(partnerKey, gameId, { userId, instanceId } = {}) {
const url = new URL(
`/play/${encodeURIComponent(partnerKey)}/${encodeURIComponent(gameId)}`,
LIL_SNACK_HOST,
);
url.searchParams.set("aid", getAnonymousId());
if (userId) url.searchParams.set("uid", userId);
if (instanceId) url.searchParams.set("instanceId", instanceId);
return url.toString();
}
2. Add the iframe
<div
id="game-container"
style="max-width: 420px; aspect-ratio: 9/16; background: #f4f2ea;"
>
<iframe
id="lil-snack-game"
title="Lil Snack game"
style="width: 100%; height: 100%; border: 0;"
loading="lazy"
allow="web-share; clipboard-write"
></iframe>
</div>
<script>
document.getElementById("lil-snack-game").src = buildGameUrl(
"PARTNER_KEY",
"GAME_ID",
{ instanceId: "game-1" },
);
</script>
A few attributes matter:
- Background. The game is transparent while it loads and fades in over whatever is behind it, so give the container a background color that matches your loading screen. The SDK page explains why.
allow="web-share; clipboard-write"lets the in-game Share button use the native share sheet, falling back to the clipboard.sandboxis optional. If you add it, include at leastallow-scripts allow-same-origin, which is what the SDK uses.- Referrer policy. Leave it at the browser default. The game uses the
embedding page's origin, taken from the referrer, to address its messages to
you. If you set
referrerpolicy="no-referrer", ask your Lil Snack contact to list your page's origin in your partner key's allowed origins.
3. Listen for events
The game posts a message to your page at each step: loaded, started, finished, and so on. Every message has the same envelope:
{
type: "lil-snack-game-end",
instanceId: "game-1", // only when you passed instanceId
data: {
gameType: "…",
gameId: "…",
archive: false,
win: true,
createdAt: "2026-10-06T12:00:00.000Z"
// …plus the fields for this event type
}
}
Check where each message came from before you trust it:
const LIL_SNACK_ORIGIN = new URL(LIL_SNACK_HOST).origin;
const iframe = document.getElementById("lil-snack-game");
window.addEventListener("message", (event) => {
// Only accept messages from the Lil Snack host, sent by this iframe.
if (event.origin !== LIL_SNACK_ORIGIN) return;
if (event.source !== iframe.contentWindow) return;
const message = event.data;
if (!message || typeof message.type !== "string") return;
switch (message.type) {
case "lil-snack-game-ready":
console.log("ready, played before:", message.data.hasPlayed);
break;
case "lil-snack-game-start":
console.log("started");
break;
case "lil-snack-game-end":
console.log(
"finished",
message.data.win,
message.data.durationActiveSeconds,
);
break;
case "lil-snack-game-share":
console.log("player tapped Share");
break;
case "lil-snack-game-error":
console.error(message.data.code, message.data.message);
break;
}
});
Compare event.origin exactly. A substring check such as
event.origin.includes("lilsnack") also matches hosts that aren't ours.
event.source === iframe.contentWindow is the reliable way to tell which game a
message came from. instanceId is a convenience on top of that.
See Events for every message type and the fields each one carries.
Not receiving anything?
- Wrong origin. Staging messages come from
https://staging.play.lilsnack.co, production fromhttps://play.lilsnack.co. Make sure your check matches the host in your iframe URL. - Your page isn't an allowed origin. If your partner key has a list of allowed origins, the game only sends messages to pages on that list. Ask your Lil Snack contact to add yours, including staging and local development origins.
- The referrer was stripped. See the referrer policy note above.
- Reading the wrong shape.
/playmessages usetypeand nest their fields underdata. Game Direct uses a flatmessageTypeshape, and code written for one won't read the other.
4. Answer storage requests
Games can ask your page to keep small values for them, such as the player's mute setting or, on the local persistence tier, the player's results. Your page is a first-party context, so its storage survives browser privacy protections that clear storage inside the game's iframe. The SDK answers these requests automatically; without it, add two cases to your listener:
const STORAGE_PREFIX = "lil-snack:";
// Inside the same message listener, after the origin and source checks:
if (message.type === "lil-snack-get-local-storage") {
const { key, requestId } = message.data;
event.source.postMessage(
{
type: "lil-snack-storage-response",
requestId,
data: { value: localStorage.getItem(STORAGE_PREFIX + key) },
},
event.origin,
);
return;
}
if (message.type === "lil-snack-set-local-storage") {
const { key, value } = message.data;
localStorage.setItem(STORAGE_PREFIX + key, value);
return;
}
Reply with value: null when you have nothing stored. If your page doesn't
answer, the game waits about three seconds and then carries on as if nothing were
stored, so a returning player starts over and every load is slower. See
Identity and Persistence for which tiers rely on this.
5. (Optional) Enable share
The share parameter is a base64-encoded JSON object: url (required) plus
optional title and text. Encode it as UTF-8 so non-ASCII text survives:
function encodeShare(share) {
const bytes = new TextEncoder().encode(JSON.stringify(share));
return btoa(String.fromCharCode(...bytes));
}
url.searchParams.set(
"share",
encodeShare({
url: "https://partner.example/puzzle",
title: "Try today's puzzle",
}),
);
url must be http or https. An invalid payload simply hides the Share
button. When a player taps Share, you receive lil-snack-game-share.
6. (Optional) Native apps (WebView)
A native app can load the game URL straight into a WebView. The game is then the top-level page, so it posts its messages to its own window instead of a parent page. Your app injects a small bridge script that listens there and passes each message to native code.
Build the URL exactly as in step 1. Generate the
anonymous ID once, store it in your app (for example UserDefaults or
SharedPreferences), and send the same value on every load.
The bridge script
Inject this at document start, before the game loads, or you'll miss the
first messages. Each platform below defines forward first, then appends this
script.
(function () {
// Use https://play.lilsnack.co once your partner key is live in production.
var LIL_SNACK_ORIGIN = "https://staging.play.lilsnack.co";
var STORAGE_PREFIX = "lil-snack:";
window.addEventListener("message", function (event) {
// The game posts to its own window, from its own origin.
if (event.origin !== LIL_SNACK_ORIGIN || event.source !== window) return;
var message = event.data;
if (!message || typeof message.type !== "string") return;
// Storage requests are answered right here. In a WebView this page's
// localStorage is first-party, so it persists like your app's own data.
if (message.type === "lil-snack-get-local-storage") {
window.postMessage(
{
type: "lil-snack-storage-response",
requestId: message.data.requestId,
data: {
value: localStorage.getItem(STORAGE_PREFIX + message.data.key),
},
},
LIL_SNACK_ORIGIN,
);
return;
}
if (message.type === "lil-snack-set-local-storage") {
localStorage.setItem(
STORAGE_PREFIX + message.data.key,
message.data.value,
);
return;
}
// Our own answer arrives here too; it isn't an event for the app.
if (message.type === "lil-snack-storage-response") return;
forward(message);
});
})();
Every other message is forwarded to your app: the same { type, instanceId, data }
envelope described in step 3 and on the
Events page.
iOS (SwiftUI)
import SwiftUI
import WebKit
/// The bridge script above, bundled with your app as a string.
let lilSnackBridgeScript = "…"
struct LilSnackGameView: UIViewRepresentable {
let gameURL: URL
var onEvent: (_ type: String, _ data: [String: Any]) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onEvent: onEvent) }
func makeUIView(context: Context) -> WKWebView {
let source = "var forward = function (m) { window.webkit.messageHandlers.lilSnack.postMessage(m); };\n"
+ lilSnackBridgeScript
let controller = WKUserContentController()
controller.addUserScript(WKUserScript(
source: source,
injectionTime: .atDocumentStart,
forMainFrameOnly: true
))
controller.add(context.coordinator, name: "lilSnack")
let config = WKWebViewConfiguration()
config.userContentController = controller
// The persistent store, so stored game state survives app restarts.
config.websiteDataStore = .default()
config.allowsInlineMediaPlayback = true
let webView = WKWebView(frame: .zero, configuration: config)
webView.load(URLRequest(url: gameURL))
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {}
final class Coordinator: NSObject, WKScriptMessageHandler {
let onEvent: (String, [String: Any]) -> Void
init(onEvent: @escaping (String, [String: Any]) -> Void) { self.onEvent = onEvent }
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard let body = message.body as? [String: Any],
let type = body["type"] as? String else { return }
onEvent(type, body["data"] as? [String: Any] ?? [:])
}
}
}
// Usage
LilSnackGameView(gameURL: url) { type, data in
if type == "lil-snack-game-end" {
print("finished, win:", data["win"] as? Bool ?? false)
}
}
Android (Kotlin)
Document-start injection needs the AndroidX WebKit library
(androidx.webkit:webkit).
import android.annotation.SuppressLint
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import org.json.JSONObject
// Use https://play.lilsnack.co once your partner key is live in production.
const val LIL_SNACK_ORIGIN = "https://staging.play.lilsnack.co"
/** The bridge script above, bundled with your app as a string. */
const val LIL_SNACK_BRIDGE_SCRIPT = "…"
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun LilSnackGameView(gameUrl: String, onEvent: (type: String, data: JSONObject) -> Unit) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
// Local storage for the bridge, so stored game state persists.
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
addJavascriptInterface(
object {
@JavascriptInterface
fun postMessage(json: String) {
val message = JSONObject(json)
// Called on a background thread: hop to the main
// thread before touching UI.
onEvent(
message.getString("type"),
message.optJSONObject("data") ?: JSONObject(),
)
}
},
"LilSnackBridge",
)
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
WebViewCompat.addDocumentStartJavaScript(
this,
"var forward = function (m) { LilSnackBridge.postMessage(JSON.stringify(m)); };\n" +
LIL_SNACK_BRIDGE_SCRIPT,
setOf(LIL_SNACK_ORIGIN),
)
}
loadUrl(gameUrl)
}
},
)
}
addJavascriptInterface exposes LilSnackBridge to every page the WebView
loads, so use this WebView for Lil Snack URLs only.
React Native
import React from "react";
import { WebView } from "react-native-webview";
/** The bridge script above, bundled with your app as a string. */
const LIL_SNACK_BRIDGE_SCRIPT = "…";
const injected =
"var forward = function (m) { window.ReactNativeWebView.postMessage(JSON.stringify(m)); };\n" +
LIL_SNACK_BRIDGE_SCRIPT +
"\ntrue;";
export function LilSnackGame({ gameUrl, onEvent }) {
return (
<WebView
source={{ uri: gameUrl }}
injectedJavaScriptBeforeContentLoaded={injected}
onMessage={(event) => {
const message = JSON.parse(event.nativeEvent.data);
onEvent(message.type, message.data);
}}
javaScriptEnabled
domStorageEnabled
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
/>
);
}
Not receiving anything in the app?
- The script ran too late. It must be injected at document start
(
.atDocumentStart,addDocumentStartJavaScript,injectedJavaScriptBeforeContentLoaded). Injecting after the page loads misses the early messages. - Wrong origin. The script's
LIL_SNACK_ORIGINhas to match the host in the URL you loaded: staging or production. - Reading the wrong shape.
/playmessages usetypeand nest their fields underdata. The Game Direct WebView samples read a flatmessageTypeshape instead.
7. Go live
Once your partner key is enabled in production, switch the host to
https://play.lilsnack.co, in both the game URL and your origin check (in a
native app, that's the bridge script's LIL_SNACK_ORIGIN).