Hero Match
Documentation

About the Game

Welcome to Hero Match documentation. This documentation covers setup, publishing, and customization so you can ship quickly.

If you need help beyond the scope of this guide, reach out via info@zupga.com.

PlayCanvas Project Import

  1. Go to playcanvas.com and log in.
  2. Open the Projects tab and click Import.
  3. Select the provided PlayCanvas project ZIP from this package.
  4. Wait for the import to finish, then open the project in the editor.
PlayCanvas Projects page with import highlighted
PlayCanvas Projects: use Import to add the project ZIP.

Publish on a Website

  1. Export your game as HTML5 from PlayCanvas.
  2. Upload the exported folder to your website hosting (FTP or a file manager).
  3. Ensure the uploaded folder contains index.html at the root.
  4. Open the URL in a browser to verify the game loads.

Tip: tools like FileZilla can make FTP uploads faster.

FTP client showing game files uploaded to a website folder
Upload the HTML5 build folder to your hosting (example FTP view).

Build Android / iOS (Capacitor)

PlayCanvas games are HTML5-based. To ship as native apps, wrap the HTML5 build with Capacitor.

Requirements

  • Node.js
  • Android Studio (Android builds)
  • Xcode (iOS builds, macOS only)

Steps

  1. Create a Capacitor app:
    npm create @capacitor/app@latest
  2. When asked for the web folder, use www.
  3. Copy your exported HTML5 build into the www folder.
  4. Install dependencies and add platforms:
    npm install
    npx cap add android
    npx cap add ios
  5. Sync assets:
    npx cap sync
  6. Open native IDEs:
    npx cap open android
    npx cap open ios

More: Capacitor docs.

Level Editor

Used to create or edit Levels.json.

Run the editor

Link for the level editor https://zupga.com/hero-match-level-editor/.

Update levels

  1. Click Load Existing JSON and choose Levels.json from the project.
  2. Edit levels (or add new one by setting the new level number the top left "Target Level Index").
  3. Choose the element you want to put in the level and click on the board to place them. Empty slots will be regular sugars. To make the slot empty, you need to use "hole".
  4. click Download Pack to export levels.json.
  5. Replace the Levels.json file with the downloaded one.
Tip

Keep a backup copy of Levels.json before overwriting it.

Ads (Web + Mobile)

Ads are managed by AdManager.js (a PlayCanvas script).

Web (Google AdSense – H5 Ad Placement API)

  1. Get your AdSense publisher id (ca-pub-...).
  2. In PlayCanvas, select the entity with AdManager.
  3. Set webClientId and (optional) webTestMode.

Showing an interstitial:

// Placement name is used as adBreak 'name'
this.app.fire('ads:showInterstitial', 'next_level');

Showing a rewarded:

this.app.fire('ads:showRewarded', { placement: 'revive' });

Mobile (Capacitor + AdMob plugin)

  1. Install a Capacitor AdMob plugin in your native project (must expose Capacitor.Plugins.AdMob).
  2. In PlayCanvas, set adUnitIdMobileInterstitial and adUnitIdMobileRewarded.
  3. Enable mobileTesting during QA.

Pausing gameplay during ads

  • pauseTimeScaleEnabled sets this.app.timeScale = 0 while the ad is running.
  • pauseAudioEnabled attempts to suspend WebAudio and pause sound slots while the ad is running.
  • pauseEventsEnabled fires game:pause and game:resume.

Supabase (Auth + DB)

Supabase provides login + cloud save. The game reads Supabase URL/Key from the User script.

Important Notes
  • The game supports offline/local fallback: if Supabase or a required SDK is missing, it uses localStorage.
  • Mobile digital goods should use native IAP (Google/Apple). Web checkout (Xsolla) is for web builds.
  • Xsolla webhook signature verification is recommended for production. You can add it later if needed.

Step-by-step

  1. Create a Supabase account → create a new project.
  2. Go to Authentication → enable Anonymous sign-ins.
  3. Go to SQL Editor → New query → paste docs/Supabase/SQL (or use the section below) → Run.
  4. If you plan to use Xsolla web payments, follow the Xsolla section to deploy the xsolla-payment Edge Function.
  5. Go to Project Settings → API and copy:
    • Project URL
    • anon public key
  6. In PlayCanvas editor, paste them into User.supabaseUrl and User.supabaseKey.
Supabase Project Settings API page showing the Project URL and anon public key
Supabase Project Settings → API page: copy Project URL and anon key.
Supabase SQL Editor with the starter SQL pasted and Run highlighted
Supabase SQL Editor: paste docs/Supabase/SQL and click Run.

SUPABASE PlayCanvas Setup (Where To Paste Keys)

  1. Open the project in PlayCanvas Editor.
  2. Open the "Boot" scene.
  3. Click on the root entity.
  4. Find the User script in the entity components (right-side).
  5. Paste values:
    • supabaseUrl = Supabase Project URL
    • supabaseKey = Supabase anon public key
  6. (Optional) Find the entity with IapManager (file: IapManager.js) and paste RevenueCat keys.
  7. (Optional) Find the entity with AdManager (file: AdManager.js) and set ad IDs.
PlayCanvas Editor showing the User.js script attributes (supabaseUrl and supabaseKey) filled in
PlayCanvas: User script attributes where you paste supabaseUrl and supabaseKey.
Optional: Cloud Save with Google / Facebook (OAuth)

Enable OAuth only if you want users to log in with Google/Facebook instead of guest accounts.

  1. Supabase → Authentication → Settings:
    • Keep Allow anonymous sign-ins enabled (guest mode).
    • Set Site URL to your published game URL.
    • Add your game URL(s) to Additional Redirect URLs (staging + localhost if needed).
  2. Supabase → Authentication → Providers:
    • Enable Google and paste Client ID + Client Secret.
    • Enable Facebook and paste App ID + App Secret.
  3. Google Cloud Console: create OAuth credentials (Web app) and add redirect URI:
    https://<YOUR_SUPABASE_PROJECT_REF>.supabase.co/auth/v1/callback
  4. Meta for Developers: create Facebook app + Facebook Login (web) and add the same redirect URI:
    https://<YOUR_SUPABASE_PROJECT_REF>.supabase.co/auth/v1/callback
Advanced reference (tables, RLS, RPC)
  • Tables: player_stats, friendships, plus view friend_list_view.
  • Row creation: the SQL script creates a trigger on auth.users to insert a default player_stats row.
  • RLS: set up by the SQL script; you can tighten policies based on your needs.
  • RPC used by the game: sync_hero_match_progress, sync_lives, use_life, set_lives, apply_bundle_reward.
Supabase SQL Script (copy/paste into SQL Editor)

Source file: docs/Supabase/SQL. Run once per Supabase project.

-- ================================================================
-- HERO MATCH GAME - FULL BACKEND SETUP
-- ================================================================

-- 1. TABLES
-- ================================================================
CREATE TABLE IF NOT EXISTS public.player_stats (
    id uuid REFERENCES auth.users NOT NULL PRIMARY KEY,
    username text,
    coins int4 DEFAULT 3000,
    stars int4 DEFAULT 0,
    level int4 DEFAULT 1,
    lives int4 DEFAULT 5,
    max_lives int4 DEFAULT 5,
    high_score int4 DEFAULT 0,
    game_data jsonb DEFAULT '{}'::jsonb,
    power_ups jsonb DEFAULT '{"rocketV": 3, "tnt": 3, "disco": 3}'::jsonb,
    boosters jsonb DEFAULT '{"hammer": 3, "laserGun": 3, "gauntlet": 3, "powerCore": 3}'::jsonb,
    last_regen_time timestamptz DEFAULT now(),
    updated_at timestamptz DEFAULT now(),
    purchased_bundles text[] DEFAULT '{}'::text[],
    avatar_id int4 DEFAULT 0,
    country_code text,
    short_id text UNIQUE
);

CREATE TABLE IF NOT EXISTS public.friendships (
    id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
    sender_id uuid REFERENCES public.player_stats(id),
    receiver_id uuid REFERENCES public.player_stats(id),
    status text DEFAULT 'pending', -- pending, accepted, rejected
    created_at timestamptz DEFAULT now()
);

-- 2. CORE FUNCTIONS
-- ================================================================

-- ID Generator
CREATE OR REPLACE FUNCTION public.generate_short_id()
 RETURNS text LANGUAGE plpgsql SECURITY DEFINER AS $function$
DECLARE
    new_id text;
BEGIN
    LOOP
        new_id := upper(substring(md5(random()::text) from 1 for 6));
        IF NOT EXISTS (SELECT 1 FROM public.player_stats WHERE short_id = new_id) THEN
            RETURN new_id;
        END IF;
    END LOOP;
END; $function$;

-- Progress & Stats Sync
CREATE OR REPLACE FUNCTION public.sync_hero_match_progress(p_level integer, p_coins integer, p_stars integer, p_high_score integer, p_game_data jsonb, p_power_ups jsonb, p_boosters jsonb)
 RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $function$
BEGIN
  INSERT INTO public.player_stats (id, level, coins, stars, high_score, game_data, power_ups, boosters, updated_at)
  VALUES (auth.uid(), p_level, p_coins, p_stars, p_high_score, p_game_data, p_power_ups, p_boosters, now())
  ON CONFLICT (id) DO UPDATE SET
    level = greatest(public.player_stats.level, excluded.level),
    coins = CASE WHEN excluded.level > public.player_stats.level THEN excluded.coins ELSE public.player_stats.coins END,
    stars = CASE WHEN excluded.level > public.player_stats.level THEN excluded.stars ELSE public.player_stats.stars END,
    game_data = CASE WHEN excluded.level > public.player_stats.level THEN excluded.game_data ELSE public.player_stats.game_data END,
    power_ups = CASE WHEN excluded.level > public.player_stats.level THEN excluded.power_ups ELSE public.player_stats.power_ups END,
    boosters = CASE WHEN excluded.level > public.player_stats.level THEN excluded.boosters ELSE public.player_stats.boosters END,
    updated_at = now();
END; $function$;

-- Lives Management
CREATE OR REPLACE FUNCTION public.sync_lives(p_id uuid, regen_seconds integer)
 RETURNS json LANGUAGE plpgsql SECURITY DEFINER AS $function$
DECLARE
  player_rec record;
  now_time timestamp with time zone := now();
  lives_gained int;
  new_lives int;
  new_regen_time timestamp with time zone;
BEGIN
  SELECT * INTO player_rec FROM public.player_stats WHERE id = p_id;
  IF NOT FOUND THEN RETURN json_build_object('error', 'Player not found'); END IF;
  IF player_rec.last_regen_time IS NULL THEN
    UPDATE public.player_stats SET last_regen_time = now_time WHERE id = p_id;
    player_rec.last_regen_time := now_time;
  END IF;
  IF player_rec.lives >= player_rec.max_lives THEN
    RETURN json_build_object('lives', player_rec.lives, 'max_lives', player_rec.max_lives, 'next_regen_seconds', 0);
  END IF;
  lives_gained := floor(extract(epoch from (now_time - player_rec.last_regen_time)) / regen_seconds);
  new_lives := LEAST(player_rec.max_lives, player_rec.lives + lives_gained);
  IF new_lives >= player_rec.max_lives THEN new_regen_time := now_time;
  ELSE new_regen_time := player_rec.last_regen_time + (lives_gained * interval '1 second' * regen_seconds); END IF;
  UPDATE public.player_stats SET lives = new_lives, last_regen_time = new_regen_time, updated_at = now_time WHERE id = p_id;
  RETURN json_build_object('lives', new_lives, 'max_lives', player_rec.max_lives, 'next_regen_seconds', GREATEST(0, regen_seconds - floor(extract(epoch from (now_time - new_regen_time)))::int));
END; $function$;

CREATE OR REPLACE FUNCTION public.use_life(p_id uuid, regen_seconds integer)
 RETURNS json LANGUAGE plpgsql SECURITY DEFINER AS $function$
DECLARE
  current_rec record;
  sync_res json;
BEGIN
  SELECT public.sync_lives(p_id, regen_seconds) INTO sync_res;
  SELECT * INTO current_rec FROM public.player_stats WHERE id = p_id;
  IF current_rec.lives <= 0 THEN
    RETURN json_build_object('error', 'No lives');
  END IF;
  UPDATE public.player_stats SET lives = current_rec.lives - 1, updated_at = now() WHERE id = p_id;
  RETURN json_build_object('lives', current_rec.lives - 1, 'max_lives', current_rec.max_lives);
END; $function$;

CREATE OR REPLACE FUNCTION public.set_lives(p_id uuid, lives integer, regen_seconds integer, max_lives integer)
 RETURNS json LANGUAGE plpgsql SECURITY DEFINER AS $function$
DECLARE
  safe_lives int := GREATEST(0, lives);
  safe_max int := GREATEST(1, max_lives);
BEGIN
  UPDATE public.player_stats
  SET lives = LEAST(safe_lives, safe_max),
      max_lives = safe_max,
      last_regen_time = now(),
      updated_at = now()
  WHERE id = p_id;
  RETURN json_build_object('lives', LEAST(safe_lives, safe_max), 'max_lives', safe_max, 'next_regen_seconds', 0);
END; $function$;

CREATE OR REPLACE FUNCTION public.add_currency(p_id uuid, coins_add integer, stars_add integer)
 RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $function$
BEGIN
  UPDATE public.player_stats SET coins = COALESCE(coins, 0) + coins_add, stars = COALESCE(stars, 0) + stars_add, updated_at = now() WHERE id = p_id;
END; $function$;

CREATE OR REPLACE FUNCTION public.apply_bundle_reward(p_id uuid, coins_to_add integer, reward_items jsonb, bundle_sku text)
 RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $function$
DECLARE
    item_key text; item_val int; current_boosters jsonb; current_powerups jsonb;
BEGIN
    SELECT COALESCE(boosters, '{}'::jsonb), COALESCE(power_ups, '{}'::jsonb) INTO current_boosters, current_powerups FROM public.player_stats WHERE id = p_id;
    FOR item_key, item_val IN SELECT * FROM jsonb_each_text(reward_items) LOOP
        IF item_key IN ('hammer', 'laserGun', 'gauntlet', 'powerCore') THEN
            current_boosters = jsonb_set(current_boosters, array[item_key], to_jsonb(COALESCE((current_boosters->>item_key)::int, 0) + item_val));
        ELSIF item_key IN ('rocketV', 'tnt', 'disco') THEN
            current_powerups = jsonb_set(current_powerups, array[item_key], to_jsonb(COALESCE((current_powerups->>item_key)::int, 0) + item_val));
        END IF;
    END LOOP;
    UPDATE public.player_stats SET coins = coins + coins_to_add, boosters = current_boosters, power_ups = current_powerups, purchased_bundles = array_append(purchased_bundles, bundle_sku), updated_at = now() WHERE id = p_id;
END; $function$;

-- Leaderboard (Fixed for JS params)
CREATE OR REPLACE FUNCTION public.get_leaderboard_v2(p_type text, p_user_id uuid, p_country_code text DEFAULT NULL)
 RETURNS TABLE(user_id uuid, username text, avatar_id integer, level integer, rank bigint) 
 LANGUAGE plpgsql SECURITY DEFINER AS $function$
BEGIN
    RETURN QUERY
    WITH RankedPlayers AS (
        SELECT 
            p.id,
            COALESCE(p.username, 'Unknown Player') as display_name,
            p.avatar_id,
            p.level,
            ROW_NUMBER() OVER (ORDER BY p.level DESC, p.updated_at ASC) as r_num
        FROM public.player_stats p
        WHERE 
            (p_type = 'global')
            OR
            (p_type = 'local' AND p.country_code = p_country_code)
            OR
            (p_type = 'friends' AND (
                p.id = p_user_id OR 
                EXISTS (
                    SELECT 1 FROM public.friendships f 
                    WHERE f.status = 'accepted' AND (
                        (f.sender_id = p_user_id AND f.receiver_id = p.id) OR
                        (f.receiver_id = p_user_id AND f.sender_id = p.id)
                    )
                )
            ))
    )
    SELECT * FROM RankedPlayers LIMIT 50;
END; $function$;

-- 3. TRIGGER FUNCTIONS
-- ================================================================
CREATE OR REPLACE FUNCTION public.handle_new_player()
 RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $function$
BEGIN
  INSERT INTO public.player_stats (id, username, coins, stars, high_score, power_ups, boosters)
  VALUES (new.id, 'Player ' || substr(new.id::text, 1, 4), 3000, 0, 0,
    jsonb_build_object('rocketV', 3, 'tnt', 3, 'disco', 3),
    jsonb_build_object('hammer', 3, 'laserGun', 3, 'gauntlet', 3, 'powerCore', 3)
  );
  RETURN new;
END; $function$;

CREATE OR REPLACE FUNCTION public.trg_set_short_id()
 RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $function$
BEGIN
    IF NEW.short_id IS NULL THEN
        NEW.short_id := public.generate_short_id();
    END IF;
    RETURN NEW;
END; $function$;

-- 4. APPLY TRIGGERS
-- ================================================================
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE PROCEDURE public.handle_new_player();

DROP TRIGGER IF EXISTS set_short_id_trigger ON public.player_stats;
CREATE TRIGGER set_short_id_trigger
  BEFORE INSERT ON public.player_stats
  FOR EACH ROW EXECUTE PROCEDURE public.trg_set_short_id();

-- 5. REFRESH API
-- ================================================================
NOTIFY pgrst, 'reload schema';
-- Enable RLS
ALTER TABLE public.player_stats ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.friendships ENABLE ROW LEVEL SECURITY;

-- Player Stats Policies
CREATE POLICY "Public profiles are viewable by everyone" 
ON public.player_stats FOR SELECT 
USING (true);

CREATE POLICY "Users can update own stats" 
ON public.player_stats FOR UPDATE 
USING (auth.uid() = id);

-- Friendships Policies
CREATE POLICY "Users can view their own friendships" 
ON public.friendships FOR SELECT 
USING (auth.uid() = sender_id OR auth.uid() = receiver_id);

CREATE POLICY "Users can create friendship requests" 
ON public.friendships FOR INSERT 
WITH CHECK (auth.uid() = sender_id);

CREATE POLICY "Users can update their own received requests" 
ON public.friendships FOR UPDATE 
USING (auth.uid() = receiver_id);

Xsolla (Payments)

Web builds use Xsolla checkout. The game requests a token from Supabase Edge Functions, then opens XPayStationWidget.

Step-by-step

  1. Create Xsolla account → create project.
  2. Create your products with SKUs matching virtual-items.json (example: coin_1000, gold_tray, One_Time_Offer).
  3. Pay Station settings: disable “Xsolla Login required” (allow guest checkout).
  4. Make sure IAP Enabled checkbox is checked on Root entity in Playcanvas "Boot" scene
  5. Supabase → Edge FunctionsDeploy a new functionVia Editor → Function name: xsolla-payment → paste docs/Supabase/Edge-Function.tsDeploy function.
  6. Disable "Verify JWT with legacy secret"
  7. Copy the Endpoint URL
  8. Add ?action=webhook at the end of your Endpoint URL. (Example:
    <YOUR_SUPABASE_FUNCTION_URL>?action=webhook
  9. Go to your app in Xsolla dashboard, click settings -> webhooks and paste it into webhook server section

Edge Function environment variables

Go to Supabase -> Edge functions -> Secrets and add these secret variables

  • XSOLLA_MERCHANT_ID (copy from Xsolla -> Top left ID, below your name)
  • XSOLLA_API_KEY (create one from Xsolla -> settings -> API keys -> +Create API Key [Key name: Supabase_Edge_Function, Key Type: Permanent])
  • XSOLLA_PROJECT_ID (copy from Xsolla -> Below your game icon, under <- Dashboard button)
  • XSOLLA_WEBHOOK_SECRET (optional, copy from Xsolla -> settings -> webhooks -> “Secret key” field)
Xsolla dashboard showing Merchant ID, API Key, Project ID, and Secret key fields
Xsolla dashboard: copy Merchant ID, API Key, Project ID, and Secret key for Supabase Edge Function secrets.
Price validation (recommended for live)
  • Set XSOLLA_CATALOG_URL to a public JSON export of your catalog (virtual_currency_packages + bundles). Use documentation/xsolla/virtual-items.json and follow these steps:
    1. Supabase → Storage → New bucket.
    2. Bucket name: catalog.
    3. Public bucket: enabled.
    4. Click the created bucket.
    5. Upload file: documentation/xsolla/virtual-items.json.
    6. Click the three dots on the uploaded file → Get URL.
    7. Supabase → Edge Functions → Secrets → create XSOLLA_CATALOG_URL and paste the URL.
  • The edge function can compare the client price with the catalog price and reject mismatches.
  • Without validation, a modified client can send a lower price.
Where do I paste these keys? (Supabase Edge Function Secrets)
  1. Supabase Dashboard → Edge Functions → open Secrets.
  2. Add each key/value:
    • XSOLLA_MERCHANT_ID, XSOLLA_API_KEY, XSOLLA_PROJECT_ID
    • XSOLLA_WEBHOOK_SECRET (optional, from Xsolla webhook settings)
    • XSOLLA_CATALOG_URL (optional, public JSON catalog URL for price validation)
  3. Then click save (or batch save if you added multiple variables at the same time).

Do not hardcode secrets inside Edge-Function.ts.

Edge Function code (xsolla-payment)

Source file: docs/Supabase/Edge-Function.ts. Paste into Supabase Edge Functions editor (Via Editor) and deploy.

Loading…
Edge Function deployment notes
  • The client calls this.database.client.functions.invoke('xsolla-payment?action=get_token'), so the function name must be xsolla-payment.
Production hardening
  • Switch Pay Station out of sandbox mode when going live (XsollaManager.js currently sets sandbox: true).
  • Restrict CORS in production (the edge function currently allows *).
  • Verify Xsolla webhook signature before granting rewards.

Mobile IAP (RevenueCat)

Native builds (Android/iOS) can use RevenueCat. SKUs must match virtual-items.json and ShopManager.js item IDs.

What’s implemented in this repo

  • IapManager.js: wrapper around the Capacitor RevenueCat plugin (Purchases)
  • ShopManager.js: automatically uses IAP on native (Capacitor) builds, otherwise uses Xsolla
  • Product IDs/SKUs are the same strings used in the shop offers (e.g. coin_1000, gold_tray, One_Time_Offer)

Setup steps

  1. Google Play Console: create in-app products with IDs matching the SKUs (example: coin_1000).
  2. App Store Connect (optional): create in-app products with the same IDs.
  3. RevenueCat: create a project → connect stores → import products.
  4. Native project (Capacitor): install @revenuecat/purchases-capacitor.
  5. PlayCanvas: add IapManager script and paste your RevenueCat API keys.
Important: local fulfillment is insecure
  • IapManager.localFulfillmentEnabled grants coins/items on the client to match Xsolla SKUs.
  • For production, replace this with server-side receipt validation and server-side reward application.

GameAnalytics

Analytics are handled by GameAnalyticsManager.js (PlayCanvas script).

Step-by-step

  1. Create a GameAnalytics project and copy Game Key + Secret Key.
  2. Open the project in PlayCanvas Editor.
  3. Open the "Boot" scene.
  4. Click on the root entity.
  5. Find the GameAnalyticsManager script in the entity components (right-side).
  6. Paste values:
    • Game Key = Gameanalytics Game Key
    • Secret Key = GameAnalytics Secret Key
  7. (Optional) Set buildVersion
GameAnalytics dashboard showing Game Key and Secret Key
GameAnalytics dashboard: copy Game Key and Secret Key.
PlayCanvas Editor showing the GameAnalyticsManager script attributes
PlayCanvas: GameAnalyticsManager script attributes in the Boot scene.

Web vs Mobile

  • Web: uses the GA web SDK already included in the project.
  • Mobile: requires a Capacitor GameAnalytics plugin (window.capacitor.plugins.GameAnalytics).

Customization

UI graphics

  • Open the PlayCanvas editor and locate UI textures in the Assets panel.
  • Replace textures with your own while keeping the names and dimensions same for clean layout.
  • Update UI elements in the scene if you change sizes or aspect ratios.

App Events

Common events fired by the game scripts (use this.app.on to listen):

  • ads:showInterstitial, ads:showRewarded (request an ad)
  • ads:interstitial:requested, ads:interstitial:started, ads:interstitial:completed
  • ads:rewarded:requested, ads:rewarded:started, ads:rewarded:completed
  • ad:complete (generic ad completion callback)
  • game:pause, game:resume (pause flow for ads)

See AdManager.js for the full list of ad-related events.

Troubleshooting

Ads

  • Web ads don’t show: confirm webClientId is valid and AdSense is approved for the site; try webTestMode.
  • Mobile AdMob “plugin not found”: ensure the Capacitor plugin is installed and exposes Capacitor.Plugins.AdMob.

Supabase

  • Falls back to local storage: check Supabase URL/key in the PlayCanvas editor and ensure the Supabase JS SDK is loaded on web.

Xsolla

  • Token request fails: check edge function env vars and that the function name matches xsolla-payment.
  • Coins not applied after purchase: verify webhook delivery and that apply_bundle_reward RPC updates player_stats.