gamemantra.aigamemantra.ai|Developer Docs

Developer Documentation · Mode B Custom Page

Mode B — custom page

Ship a completely custom Experience offer page while keeping every operator action (text, image, tap target, animation, font) bindable from the dashboard. The page is built once; the non-technical operator wires content per-segment, per-campaign, every day, without touching code.

Two layers, never confused
  • Layer 2 — Dart template. Studio developers commit it, the platform compiles it inside the preview sandbox, then ships it to devices via LiveSync code push. Editing happens rarely.
  • Layer 3 — bindings JSON. Operators edit it daily. Goes live in ~60 seconds via the existing OTA config workflow. No LiveSync, no Play Store, no app update.
1

Pick a starter

Dashboard → Offer builder → New custom page shows the starter gallery. Each starter is a complete buildCustomOffer file you can use as-is or fork. The two shipped starters are:

Starter — Offer popup

Single hero image + headline + price button + "No thanks" decline, with an optional legal footnote. Works for any genre.

lib/custom_offer_registry.dart
// Layer-2 — Dart template the studio commits.
import 'package:flutter/widgets.dart';
import 'package:gamemantra_sdk/gamemantra_sdk.dart';

// Required contract. Renamed or wrong-signature `buildCustomOffer` is
// rejected by the validator at preview time.
Widget? buildCustomOffer(String offerType, OfferModel offer) {
  return const Padding(
    padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        // Every dynamic text is a GMText slot.
        GMText(id: 'headline', fallback: 'Limited offer',
               style: TextStyle(color: Color(0xFFFFFFFF),
                                fontSize: 22, fontWeight: FontWeight.w700),
               textAlign: TextAlign.center),
        SizedBox(height: 6),
        GMText(id: 'subtitle', fallback: 'Tap to claim before it ends.',
               style: TextStyle(color: Color(0xCCFFFFFF), fontSize: 14),
               textAlign: TextAlign.center),
        SizedBox(height: 24),

        // The CTA is a GMHotspot — bindable to dismiss/decline/purchase.
        GMHotspot(
          id: 'buy',
          child: SizedBox(
            height: 56,
            child: Center(child: GMText(
              id: 'price_label', fallback: 'Get offer',
              style: TextStyle(color: Color(0xFFFFFFFF), fontSize: 17,
                               fontWeight: FontWeight.w700),
            )),
          ),
        ),
      ],
    ),
  );
}
2

The template contract

Your file must export exactly this function. Any other signature is a validator hard-block:

snippet
Widget? buildCustomOffer(String offerType, OfferModel offer)
  • Every tappable element must be a GMHotspot. Raw GestureDetector / InkWell / onTap: outside a hotspot triggers a validator warning — those buttons can't be wired in the binder.
  • Every dynamic string must be a GMText, every dynamic image a GMImage, every region the operator may hide a GMVisible.
  • No AnimationController / Ticker. In embedded Experience (Unity/Unreal) those don't fire. Use the Layer-3 animations binding instead — the SDK's Timer-driven GMAnimator runs them.
  • No dart:io / dart:ffi sockets, files, processes, or Isolate.spawnUri. All HTTP must go through the SDK.
2A

Iterating offer.bundleConfig.items — three patterns

Offers ship a variable-length item list. Three working patterns, all copy-paste ready — switch tabs to see each as a complete buildCustomOffer file you can paste into the Custom Page editor and click Preview.

Pattern 1 — GMList (dynamic loop)

The SDK iterates offer.bundleConfig.items and runs itemBuilder per item. 0 items renders nothing, N items renders N tiles. No fixed cap.

✓ Good for

Variable item count (1–N), per-tile GMHotspot for catalog buys (item context auto-propagated via GMItemScope).

⚠ Trade-off

Per-tile name/icon/qty are NOT individually bindable from the dashboard.

lib/custom_offer_registry.dart — Pattern 1
// Match-3 popup — Pattern 1: GMList dynamic loop.
//
// Paste into Custom Page editor (Offer Builder → Custom page) → Preview.
// The SDK iterates offer.bundleConfig.items and runs itemBuilder per item;
// 0 items renders nothing, N items renders N tiles. No fixed-slot cap.

import 'package:flutter/material.dart';
import 'package:gamemantra_sdk/gamemantra_sdk.dart';

Widget? buildCustomOffer(String offerType, OfferModel offer) {
  return _Pattern1Popup(offer: offer);
}

const String _kArtBase =
    'https://raw.githubusercontent.com/Rimaethon/Gem-Match3'
    '/refs/heads/master/Assets/Art';

class _Pattern1Popup extends StatelessWidget {
  final OfferModel offer;
  const _Pattern1Popup({required this.offer});

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 360,
      child: Stack(
        clipBehavior: Clip.none,
        children: [
          // Red-gold card background.
          Positioned.fill(
            child: Image.network(
              _kArtBase + '/UI/Bars/RectanglePanel.png',
              fit: BoxFit.fill,
              errorBuilder: (_, __, ___) => Container(
                decoration: BoxDecoration(
                  color: const Color(0xFF8B1A1A),
                  borderRadius: BorderRadius.circular(20),
                ),
              ),
            ),
          ),

          Padding(
            padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  (offer.title ?? 'SPECIAL OFFER').toUpperCase(),
                  textAlign: TextAlign.center,
                  style: const TextStyle(
                    color: Color(0xFFFFF2D5), fontSize: 26,
                    fontWeight: FontWeight.w800,
                  ),
                ),
                if (offer.subtitle != null) ...[
                  const SizedBox(height: 6),
                  Text(offer.subtitle!, textAlign: TextAlign.center,
                    style: const TextStyle(
                      color: Color(0xCCFFF2D5), fontSize: 14)),
                ],
                const SizedBox(height: 20),

                // ─── THE LOOP — GMList iterates dynamically ────────────
                SizedBox(
                  height: 110,
                  child: GMList(
                    id: 'bundle_items',
                    direction: GMListDirection.horizontal,
                    gap: 12,
                    itemBuilder: (ctx, item) => _ItemTile(item: item),
                  ),
                ),
                // ───────────────────────────────────────────────────────

                const SizedBox(height: 20),

                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  crossAxisAlignment: CrossAxisAlignment.baseline,
                  textBaseline: TextBaseline.alphabetic,
                  children: [
                    if (offer.originalPriceUsd > offer.priceUsd)
                      Text('\$' + offer.originalPriceUsd.toStringAsFixed(2),
                        style: const TextStyle(
                          color: Color(0xAAFFF2D5), fontSize: 18,
                          decoration: TextDecoration.lineThrough)),
                    const SizedBox(width: 10),
                    Text('\$' + offer.priceUsd.toStringAsFixed(2),
                      style: const TextStyle(
                        color: Color(0xFFFFF2D5), fontSize: 30,
                        fontWeight: FontWeight.w800)),
                  ],
                ),
                const SizedBox(height: 16),

                // Buy CTA — GMHotspot routes through the SDK purchase flow.
                GMHotspot(
                  id: 'buy',
                  child: Container(
                    height: 60, width: double.infinity,
                    alignment: Alignment.center,
                    decoration: BoxDecoration(
                      gradient: const LinearGradient(
                        colors: [Color(0xFFFFB347), Color(0xFFFF6B35)]),
                      borderRadius: BorderRadius.circular(14),
                    ),
                    child: const Text('PLAY',
                      style: TextStyle(
                        color: Colors.white, fontSize: 22,
                        fontWeight: FontWeight.w800, letterSpacing: 1.2)),
                  ),
                ),
                const SizedBox(height: 8),
                const GMHotspot(
                  id: 'skip',
                  haptic: false,
                  child: SizedBox(
                    height: 36,
                    child: Center(child: Text('No thanks',
                      style: TextStyle(
                        color: Color(0xAAFFF2D5), fontSize: 13,
                        decoration: TextDecoration.underline))),
                  ),
                ),
              ],
            ),
          ),

          // Close ✕ — anchored top-right via plain Positioned.
          Positioned(
            top: 12, right: 12,
            child: GMHotspot(
              id: 'close',
              child: Container(
                width: 44, height: 44,
                decoration: const BoxDecoration(
                  color: Color(0xCCEF4444), shape: BoxShape.circle),
                child: const Center(child: Text('✕',
                  style: TextStyle(color: Colors.white, fontSize: 22))),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// Tile rendered by GMList's itemBuilder. Reads name + iconUrl + quantity
// straight off the BundleItem — no slot ids, not operator-bindable.
class _ItemTile extends StatelessWidget {
  final BundleItem item;
  const _ItemTile({required this.item});

  @override
  Widget build(BuildContext context) {
    final hasIcon = item.iconUrl != null && item.iconUrl!.isNotEmpty;
    return SizedBox(
      width: 80,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 64, height: 64,
            decoration: BoxDecoration(
              color: const Color(0xCC1A1A22),
              borderRadius: BorderRadius.circular(10),
              border: Border.all(color: const Color(0x33FFFFFF))),
            padding: const EdgeInsets.all(8),
            child: hasIcon
              ? Image.network(item.iconUrl!, fit: BoxFit.contain,
                  errorBuilder: (_, __, ___) => const Icon(
                    Icons.inventory_2, color: Color(0xFFFFD54F)))
              : const Icon(Icons.inventory_2, color: Color(0xFFFFD54F)),
          ),
          const SizedBox(height: 4),
          Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis,
            textAlign: TextAlign.center,
            style: const TextStyle(
              color: Color(0xFFFFF2D5), fontSize: 11,
              fontWeight: FontWeight.w600)),
          Text('×' + item.quantity.toString(),
            style: const TextStyle(
              color: Color(0xFFFFD54F), fontSize: 12,
              fontWeight: FontWeight.w800)),
        ],
      ),
    );
  }
}

The Layer-3 JSON for this template only needs to bind the actions — items are read straight from the offer:

bindings JSON (Pattern 1)
{
  "actionBindings": {
    "purchase": ["buy"],
    "dismiss":  ["close"],
    "decline":  ["skip"]
  }
}
Which to pick
  • Variable item count + no per-tile binding needed → Pattern 1 (GMList).
  • Fixed-N tiles + operators should override individual slots → Pattern 2 (reserved item_<N>_*).
  • Studio has its own per-id asset map + binding not required → Pattern 3 (raw iteration).
  • All three can coexist in the same template — pick the right tool per loop.
3

The bindings (Layer 3)

Operators publish this JSON via the existing OTA config workflow. It ships in seconds, without LiveSync and without a Play Store update:

bindings JSON
{
  "template_version": 7,
  "actionBindings": {
    "purchase": ["buy"],
    "dismiss":  ["close"],
    "decline":  ["skip"]
  },
  "texts": {
    "headline":    "🔥 Last 30 minutes",
    "subtitle":    "Save 50% on the Mega Pack",
    "price_label": "Get it — $1.99"
  },
  "images":  { "hero": "https://media.gamemantra.ai/studios/images/assets/<env>/<studio>/<game>/<sha>.png" },
  "visible": { "subtitle_visible": true, "legal_footnote": false }
}
  • actionBindings.<action>: [hotspot_id, ...] — multiple hotspots can fire the same action (two buy buttons → both bound to purchase).
  • Actions are a closed set: purchase, dismiss, decline, navigate.
  • texts maps a GMText slot id to its current string. Per-segment / per-campaign overrides happen in the same dashboard surface as Mode A.
  • images values are absolute URLs from the Phase-1 asset pipeline (content-addressed by SHA-256 — overwrite-safe).
  • visible.<id>: false hides a GMVisible region. Useful for jurisdictions that require / forbid legal copy.
  • template_version pins which Layer-2 build these bindings were authored against. Unknown / missing slots fall back gracefully; the dashboard surfaces a "template changed — re-check bindings" notice when the live template moves.
4

Animations (Layer 3, in the same bindings JSON)

Animations are data, not code. The operator picks a slot, a trigger (on_show / on_idle / on_tap / on_dismiss / on_action / on_timer), and a type (fade, scale, slide, pulse, confetti …). Each animation is a small object inside bindings.animations.<slot_id>:

animations JSON
{
  "animations": {
    "buy": [
      { "trigger": "on_show",  "type": "scale",  "from": 0.92, "to": 1.0,
        "duration": 240, "easing": "outBack", "delay": 100 },
      { "trigger": "on_idle",  "type": "pulse",  "amplitude": 0.04,
        "duration": 1400, "easing": "inOutSine", "loop": true }
    ],
    "ribbon": [
      { "trigger": "on_show",  "type": "slide", "from": [-20, 0], "to": [0, 0],
        "duration": 200, "easing": "outCubic" }
    ]
  }
}

The closed type set is documented in MODE_B_CUSTOM_PAGE_PLAN.md §28.4. The closed easing set mirrors DOTween's ten most useful curves (linear, outSine, inOutSine, outCubic, inOutCubic, outBack, outElastic, outBounce, outQuart, outQuint).

4A

Responsiveness & device orientation

A custom page must render correctly on any surface — and that surface is never fixed:

  • a real phone is ~360–430 logical px wide;
  • embedded Experience (Unity / Unreal) runs pixelRatio = 1.0, so the logical canvas is 2–3× a phone — a full-page there can be 1000–1300 px wide;
  • and the player can rotate the device mid-offer if the game allows it.

What the SDK guarantees

On rotation the host re-sends a window-metrics event to the Experience engine (Unity FlutterBootstrap.HandleResize / Unreal checkScreenResized), so Experience re-runs layout with swapped constraints. The SDK's offer route is pure constraint layout — Stack(StackFit.expand) + Center — so it refills and re-centres automatically. State survives the flip: timer-driven animations keep running, scroll position and any in-flight purchase lock are preserved (Experience keeps the State objects).

What your template must do

  • Never hardcode a width that can overflow. A popup card should cap to min(360, availableWidth − margin) via a LayoutBuilder so it shrinks on a narrow phone instead of clipping.
  • Scale fonts on a full-page. A full-page fills the canvas, so fixed font sizes look like specks on the wide embedded canvas. Multiply sizes by an sf factor derived from the (capped) content width, or use the SDK's GmScaledLayout.
  • Guard vertical overflow. Wrap the body in SingleChildScrollView + ConstrainedBox(minHeight) + IntrinsicHeight — the canonical "centre vertically, scroll if too short" recipe. A short landscape viewport then scrolls instead of throwing an overflow error.
  • Branch the layout per orientation. A stacked portrait layout gets crushed in a short landscape window. Check constraints.maxWidth > constraints.maxHeight inside the LayoutBuilder and render a distinct wide layout (e.g. pitch and CTA side-by-side).
orientation-aware buildCustomOffer
// Branch the WHOLE layout on orientation, not just the sizes.
// The host (Unity FlutterBootstrap.HandleResize / Unreal
// checkScreenResized) pushes a window-metrics event to the Experience
// engine on every rotation, so this LayoutBuilder rebuilds with the
// swapped constraints — `isLandscape` flips automatically.

Widget? buildCustomOffer(String offerType, OfferModel offer) {
  return LayoutBuilder(
    builder: (context, c) {
      final isLandscape = c.maxWidth > c.maxHeight;
      // Both branches use the SAME GM* slots — an operator binds once,
      // both orientations update. State (timers / scroll / purchase
      // lock) survives the rotation: Experience keeps the State objects.
      return isLandscape
          ? _wideLayout(offer)    // e.g. pitch | CTA, side by side
          : _tallLayout(offer);   // e.g. pitch above, CTA below
    },
  );
}
The full-page starter already does all of this
The Starter full-page offer (in the starter gallery under the offer_popup page type) ships both layouts: a stacked portrait body and a two-column landscape body, sharing the exact same GM* slots. The Starter offer popup caps its width on narrow screens and scrolls on short ones. Clone either and the responsive scaffolding is already in place — you only restyle.
5

The preview / push pipeline

  1. Save the draft (autosaves on every keystroke; SHA-keyed so a no-op save is a no-op).
  2. Press Preview. The dashboard enqueues a build job; the preview-builder sidecar compiles your Dart inside the harness (60–90 s first build, ~10–20 s on re-build). On success the preview iframe loads; on failure the validator + analyzer errors are pinned to the offending lines.
  3. Click anything in the preview — the right rail opens its binder. Edits push back into the running iframe via postMessage and the page live-re-renders.
  4. Save bindings autosaves Layer-3 (no LiveSync). Make it live publishes Layer-3 instantly via the M6 OTA workflow.
  5. Push template (developer view only) pushes the Layer-2 Dart via LiveSync. Default is admin approval; per-game allow_direct_shorebird_push = true waives approval for studios that want it.
6

Test on a real device

Each game can optionally register a staging LiveSync app id (games.shorebird_staging_app_id). When set, the editor adds a Send to test device button that pushes the current draft to the staging track. Internal testers on the staging APK receive the patch within seconds; production traffic is untouched.

7

The contract you can lean on

  • Every push goes through validation → preview → device-test. The validator runs identically at preview time and push time — no bypass possible.
  • The compile sandbox has no outbound network, runs as non-root with CPU / memory / time caps, and is destroyed after every build.
  • The gamemantra header + footer always render. The template paints under them; chrome is SDK-owned and non-removable.
  • Studios supply one Dart file — never a pubspec, never new dependencies. The harness fixes the dep tree.
Looking for the full plan?
The authoritative reference is MODE_B_CUSTOM_PAGE_PLAN.md at the repo root. CLAUDE.md §60 holds the platform-wide invariants. See also /integration for SDK onboarding and /ui-runtime for the embedded Experience constraints (Timer.periodic, BlockPointer, etc.).