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
- Go to playcanvas.com and log in.
- Open the Projects tab and click Import.
- Select the provided PlayCanvas project ZIP from this package.
- Wait for the import to finish, then open the project in the editor.
Publish on a Website
- Export your game as HTML5 from PlayCanvas.
- Upload the exported folder to your website hosting (FTP or a file manager).
- Ensure the uploaded folder contains
index.htmlat the root. - Open the URL in a browser to verify the game loads.
Tip: tools like FileZilla can make FTP uploads faster.
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
- Create a Capacitor app:
npm create @capacitor/app@latest - When asked for the web folder, use
www. - Copy your exported HTML5 build into the
wwwfolder. - Install dependencies and add platforms:
npm install npx cap add android npx cap add ios - Sync assets:
npx cap sync - Open native IDEs:
npx cap open android npx cap open ios
More: Capacitor docs.
Level Editor
Used to create or editLevels.json.
Run the editor
Link for the level editor https://zupga.com/hero-match-level-editor/.
Update levels
- Click Load Existing JSON and choose
Levels.jsonfrom the project. - Edit levels (or add new one by setting the new level number the top left "Target Level Index").
- 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".
- click Download Pack to export
levels.json. - Replace the
Levels.jsonfile with the downloaded one.
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)
- Get your AdSense publisher id (
ca-pub-...). - In PlayCanvas, select the entity with
AdManager. - Set
webClientIdand (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)
- Install a Capacitor AdMob plugin in your native project (must expose
Capacitor.Plugins.AdMob). - In PlayCanvas, set
adUnitIdMobileInterstitialandadUnitIdMobileRewarded. - Enable
mobileTestingduring QA.
Pausing gameplay during ads
pauseTimeScaleEnabledsetsthis.app.timeScale = 0while the ad is running.pauseAudioEnabledattempts to suspend WebAudio and pause sound slots while the ad is running.pauseEventsEnabledfiresgame:pauseandgame:resume.
Supabase (Auth + DB)
Supabase provides login + cloud save. The game reads Supabase URL/Key from the User script.
- 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
- Create a Supabase account → create a new project.
- Go to Authentication → enable Anonymous sign-ins.
- Go to SQL Editor → New query → paste
docs/Supabase/SQL(or use the section below) → Run. - If you plan to use Xsolla web payments, follow the Xsolla section to deploy the
xsolla-paymentEdge Function. - Go to Project Settings → API and copy:
- Project URL
- anon public key
- In PlayCanvas editor, paste them into
User.supabaseUrlandUser.supabaseKey.
docs/Supabase/SQL and click Run.SUPABASE PlayCanvas Setup (Where To Paste Keys)
- Open the project in PlayCanvas Editor.
- Open the "Boot" scene.
- Click on the root entity.
- Find the
Userscript in the entity components (right-side). - Paste values:
supabaseUrl= Supabase Project URLsupabaseKey= Supabase anon public key
- (Optional) Find the entity with
IapManager(file:IapManager.js) and paste RevenueCat keys. - (Optional) Find the entity with
AdManager(file:AdManager.js) and set ad IDs.
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.
- 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).
- Supabase → Authentication → Providers:
- Enable Google and paste Client ID + Client Secret.
- Enable Facebook and paste App ID + App Secret.
- Google Cloud Console: create OAuth credentials (Web app) and add redirect URI:
https://<YOUR_SUPABASE_PROJECT_REF>.supabase.co/auth/v1/callback - 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 viewfriend_list_view. - Row creation: the SQL script creates a trigger on
auth.usersto insert a defaultplayer_statsrow. - 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
- Create Xsolla account → create project.
- Create your products with SKUs matching
virtual-items.json(example:coin_1000,gold_tray,One_Time_Offer). - Pay Station settings: disable “Xsolla Login required” (allow guest checkout).
- Make sure IAP Enabled checkbox is checked on Root entity in Playcanvas "Boot" scene
- Supabase → Edge Functions → Deploy a new function → Via Editor → Function name:
xsolla-payment→ pastedocs/Supabase/Edge-Function.ts→ Deploy function. - Disable "Verify JWT with legacy secret"
- Copy the Endpoint URL
- Add ?action=webhook at the end of your Endpoint URL. (Example:
<YOUR_SUPABASE_FUNCTION_URL>?action=webhook - 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)
- Set
XSOLLA_CATALOG_URLto a public JSON export of your catalog (virtual_currency_packages + bundles). Usedocumentation/xsolla/virtual-items.jsonand follow these steps:- Supabase → Storage → New bucket.
- Bucket name:
catalog. - Public bucket: enabled.
- Click the created bucket.
- Upload file:
documentation/xsolla/virtual-items.json. - Click the three dots on the uploaded file → Get URL.
- Supabase → Edge Functions → Secrets → create
XSOLLA_CATALOG_URLand 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)
- Supabase Dashboard → Edge Functions → open
Secrets. - Add each key/value:
XSOLLA_MERCHANT_ID,XSOLLA_API_KEY,XSOLLA_PROJECT_IDXSOLLA_WEBHOOK_SECRET(optional, from Xsolla webhook settings)XSOLLA_CATALOG_URL(optional, public JSON catalog URL for price validation)
- 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 bexsolla-payment.
- Switch Pay Station out of sandbox mode when going live (
XsollaManager.jscurrently setssandbox: 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
- Google Play Console: create in-app products with IDs matching the SKUs (example:
coin_1000). - App Store Connect (optional): create in-app products with the same IDs.
- RevenueCat: create a project → connect stores → import products.
- Native project (Capacitor): install
@revenuecat/purchases-capacitor. - PlayCanvas: add
IapManagerscript and paste your RevenueCat API keys.
IapManager.localFulfillmentEnabledgrants 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
- Create a GameAnalytics project and copy Game Key + Secret Key.
- Open the project in PlayCanvas Editor.
- Open the "Boot" scene.
- Click on the root entity.
- Find the
GameAnalyticsManagerscript in the entity components (right-side). - Paste values:
Game Key= Gameanalytics Game KeySecret Key= GameAnalytics Secret Key
- (Optional) Set
buildVersion
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:completedads:rewarded:requested,ads:rewarded:started,ads:rewarded:completedad: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
webClientIdis valid and AdSense is approved for the site; trywebTestMode. - 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_rewardRPC updatesplayer_stats.