Skip to main content

Cloud Data: Cloud Counter

A minimal cloud-persisted counter. The script declares a one-key schema ({ tapCount: 0 }), passes it to new APJS.CloudDataManager(schema), fetches the persisted state with loadData(onSuccess, onError), and writes a partial update on every tap with saveData({ tapCount: N }, onSuccess, onError). A second readout shows the server-managed EffectUsageInfo.userUsageDays counter — useful for "daily streak" mechanics that must resist tampering.

Cloud Counter demo running in Effect House preview
Cloud isn't available in TTEH preview

The cloud-data backend is reachable on real TikTok devices but not in the local Effect House preview. The error callbacks therefore fire in the editor and the demo flips into "local-only mode". On a real device the persisted tapCount survives effect reloads and the Usage day counter increments once per calendar day. The tutorial is written so the logic is correct end-to-end — the only thing the editor preview can't exercise is the actual server round-trip.

What you'll build

  • A 2D HUD with four labels — title, large tap-count display, usage-day display, and a status line that mirrors the cloud lifecycle (Loading, Loaded, Saving, Saved, Cloud unavailable).
  • A green tap button (Screen Image) hit-tested via APJS.TouchUtils.isScreenPointOnImage, so taps outside the button are ignored.
  • A CloudCounter script on a GameController empty SceneObject. It builds a CloudDataManager with a one-key schema (tapCount: 0), loads the persisted state, displays EffectUsageInfo.userUsageDays, and saves a partial update on every tap.
  • An error fallback that flips the UI into "local-only mode" if the cloud is unreachable — the local count keeps incrementing so the demo is still usable in the editor.

Open the demo

↓ cloud-counter.zip

Unzip and open in Effect House (5.9.0+). The opening scene contains:

  • Camera — default 3D perspective camera, untouched.
  • 2D Camera — auto-created when the first 2D Text was added.
  • TitleText — "Cloud Counter" at the top.
  • CountText — large yellow "Taps: N" near the upper third.
  • DayText — smaller "Usage day: N" beneath the count.
  • TapButton — green Screen Image at the lower third with a ButtonLabel child Text saying "Tap me!".
  • StatusText — small grey line near the bottom that mirrors the cloud lifecycle.
  • GameController — empty SceneObject hosting the CloudCounter script.

Read the script

CloudCounter.ts

@component()
export class CloudCounter extends APJS.BasicScriptComponent {
// 2D Text labels for the live HUD.
@serializeProperty countText!: APJS.SceneObject;
@serializeProperty dayText!: APJS.SceneObject;
@serializeProperty statusText!: APJS.SceneObject;

// The green Screen Image button — tapped to increment the cloud-persisted
// counter. We hit-test against this image's rendered bounds so a tap
// outside it is ignored.
@serializeProperty tapButton!: APJS.SceneObject;

private countTextComp!: APJS.Text;
private dayTextComp!: APJS.Text;
private statusTextComp!: APJS.Text;
private buttonImage!: APJS.Image;

// CloudDataManager owns the save/load lifecycle. Built once during init
// with a schema (default values for every key). Only `number` and `string`
// values are supported, and the total serialized payload must stay under
// 1 KB.
private cloudData!: APJS.CloudDataManager;
private touchCallback!: (e: APJS.IEvent) => void;

// Local mirror of the cloud-persisted state.
private tapCount: number = 0;
private inited: boolean = false;
private isReady: boolean = false; // flips after loadData callback resolves

// Lazy init in onUpdate — @serializeProperty references are null in the
// very first onStart frame. Wait until they're populated, then build
// everything.
onUpdate(_dt: number): void {
if (this.inited) return;
if (!this.countText || !this.dayText || !this.statusText || !this.tapButton) return;

this.countTextComp = this.countText.getComponent("Text") as APJS.Text;
this.dayTextComp = this.dayText.getComponent("Text") as APJS.Text;
this.statusTextComp = this.statusText.getComponent("Text") as APJS.Text;
this.buttonImage = this.tapButton.getComponent("Image") as APJS.Image;

// Schema declares every key the script will save or load, plus default
// values that come back when the cloud has nothing yet (first run / new
// user). Add additional keys here if you persist more state.
const schema = { tapCount: 0 };
this.cloudData = new APJS.CloudDataManager(schema);

this.statusTextComp.text = "Loading from cloud...";

// loadData fetches the persisted payload (or the schema defaults on
// first run). The success callback receives the merged object; the
// error callback fires if the user is offline or the cloud-data
// feature is disabled in this environment (e.g. TTEH preview without
// cloud).
this.cloudData.loadData(
(data) => {
this.tapCount = data.tapCount as number;

// EffectUsageInfo is a server-managed counter — number of distinct
// calendar days the user has opened this effect. Tamper-proof and
// useful for daily-streak logic. Always >= 0.
const usageInfo = this.cloudData.getEffectUsageInfo();
this.dayTextComp.text = "Usage day: " + usageInfo.userUsageDays;

this.refreshCount();
this.statusTextComp.text = "Loaded from cloud — ready";
this.isReady = true;
console.log("[CloudCounter] loaded tapCount=" + this.tapCount + " usageDays=" + usageInfo.userUsageDays);
},
(error) => {
// Cloud is unavailable. Show the schema defaults and accept taps
// so the user still has a working UI; saves will fail with a
// status message but the local count keeps incrementing.
console.error("[CloudCounter] load failed: " + error);
this.statusTextComp.text = "Cloud unavailable — local-only mode";
this.dayTextComp.text = "Usage day: –";
this.refreshCount();
this.isReady = true;
},
);

this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase !== APJS.TouchPhase.Began) return;
if (!this.isReady) return;
// TouchUtils.isScreenPointOnImage hit-tests a normalized touch
// position against the Image's rendered bounds. Pass
// touchInfo.position directly — no axis remapping needed.
if (!APJS.TouchUtils.isScreenPointOnImage(t.position, this.buttonImage)) return;
this.handleTap();
};
APJS.EventManager.getGlobalEmitter()
.on(APJS.EventType.Touch, this.touchCallback, this);

this.inited = true;
}

onDestroy(): void {
if (this.touchCallback) {
APJS.EventManager.getGlobalEmitter()
.off(APJS.EventType.Touch, this.touchCallback, this);
}
}

private handleTap(): void {
this.tapCount += 1;
this.refreshCount();
this.statusTextComp.text = "Saving...";

// saveData accepts a partial update — keys missing from the object
// retain their previously-persisted value. The success callback
// fires once the write is confirmed; the error callback indicates
// a network or permission failure (the local count is unchanged
// either way, since we already incremented before calling saveData).
this.cloudData.saveData(
{ tapCount: this.tapCount },
() => {
this.statusTextComp.text = "Saved! Cloud has tapCount=" + this.tapCount;
},
(err) => {
this.statusTextComp.text = "Save failed (see logs)";
console.error("[CloudCounter] save failed: " + err);
},
);
}

private refreshCount(): void {
this.countTextComp.text = "Taps: " + this.tapCount;
}
}

The Cloud-Data-namespace calls of interest:

  • new APJS.CloudDataManager(schema)schema is a plain JS object whose keys define every field the script can save or load, with the default value to return when the cloud is empty (first run, new user, or the field hasn't been written yet). Only number and string value types are supported, and the total serialized payload must stay under 1 KB. Keys missing from the schema are silently ignored when you call saveData.
  • cloudData.loadData(onSuccess, onError) — async fetch. The success callback receives the merged payload (persisted keys overlaid on the schema defaults). The error callback fires on network failure or when the cloud feature is disabled in the current environment — including the TTEH preview without device cloud.
  • cloudData.saveData(partial, onSuccess?, onError?) — async write. The first argument is a partial update; keys not present retain their previously-persisted value. The two callback arguments are optional but worth wiring so the UI can mirror save state. Calls are cheap to make; the engine batches/throttles network traffic.
  • cloudData.getEffectUsageInfo() — returns an EffectUsageInfo object with userUsageDays, the server-managed count of distinct calendar days this user has opened this effect. Tamper-proof — clients can't spoof it — which makes it the canonical "what day is the streak on?" signal for daily-content effects.
  • @serializeProperty constraint reminderCloudDataManager, EffectUsageInfo, and APJS.Image/APJS.Text are runtime-only references; we serialize the host SceneObject and call getComponent("ClassName") for the Text/Image lookups, while the CloudDataManager itself is constructed in script (no inspector wiring).

Why a JSON schema instead of a Map<string, any>?

CloudDataManager's "you must declare every key up front" pattern is the shape used by the public API design — same as localStorage with strict typing. The benefits worth knowing about:

  1. Defaults are inline. First-run users see meaningful values immediately. Without a schema, every read would have to coalesce data.tapCount ?? 0 at the call site.
  2. The 1 KB cap is enforceable. The engine knows the maximum size from your schema and refuses oversized writes.
  3. Type stability. Each key has a fixed type (number or string), so the TypeScript cast data.tapCount as number is safe.

The companion Story Quiz Progression template (in effect_house_scripting_templates/Templates/Story Quiz Progression) shows a richer schema — three keys (retries, lastUsageDays, cleared) driving a daily-progression state machine on top of userUsageDays. Worth a read once you've built this counter.

Customize

On GameControllerCloudCounter:

  • The script has no tunables exposed in the inspector. Customisation happens in code:
    • Add a key to the schema to persist more state (e.g. a lastTapTime: 0 to debounce double-clicks). Match the type rules (number or string only) and keep total size under 1 KB.
    • Replace the tap target. The hit-test uses APJS.TouchUtils.isScreenPointOnImage against the Image on the wired SceneObject — drop in any Screen Image with interactable: true.
    • Reset on tap-and-hold. Subscribe to APJS.TouchPhase.Ended and measure the elapsed time; on a long press, call cloudData.saveData({ tapCount: 0 }) to reset.

Suggestions for further play:

  • Add a lastUsageDays key to the schema, snapshot it on every save, and detect "the user just rolled into a new calendar day" by comparing lastUsageDays against userUsageDays on load — that's the entry point for daily-streak effects.
  • Combine with the Audio tutorial's AudioComponent.play() to fire a SFX on save success.
  • Render a 1 KB usage bar by summing the JSON-string size of the current state and showing it as a fraction of the cap.

What you learned

This tutorial used:

  • APJS.CloudDataManagernew CloudDataManager(schema), loadData(onSuccess, onError), saveData(partial, onSuccess?, onError?), plus the getEffectUsageInfo() accessor.
  • Schema semantics — every key declared with a default; only number and string types; total ≤ 1 KB.
  • Partial savessaveData({ x: 1 }) retains the previously- persisted value of every other key.
  • APJS.EffectUsageInfouserUsageDays, server-managed and tamper-proof; the canonical signal for "what day of the streak is the user on".
  • Error fallback patterns — flip the UI to "local-only mode" when the cloud is unreachable so the demo stays usable in the editor.
  • APJS.TouchUtils.isScreenPointOnImage — hit-testing a touch against an Image's rendered bounds without coordinate conversion.

Read the full CloudDataManager reference, the EffectUsageInfo reference, and the Cloud Data namespace overview.

For touch hit-testing, see TouchUtils and TouchData.

Copyright © 2026 TikTok. All rights reserved.
About TikTokHelp CenterCareersContactLegalTerms of ServicePrivacy PolicyCookies