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.

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
CloudCounterscript on aGameControllerempty SceneObject. It builds aCloudDataManagerwith a one-key schema (tapCount: 0), loads the persisted state, displaysEffectUsageInfo.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
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
CloudCounterscript.
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)—schemais 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). Onlynumberandstringvalue types are supported, and the total serialized payload must stay under 1 KB. Keys missing from the schema are silently ignored when you callsaveData.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 anEffectUsageInfoobject withuserUsageDays, 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.@serializePropertyconstraint reminder —CloudDataManager,EffectUsageInfo, andAPJS.Image/APJS.Textare runtime-only references; we serialize the host SceneObject and callgetComponent("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:
- Defaults are inline. First-run users see meaningful values
immediately. Without a schema, every read would have to coalesce
data.tapCount ?? 0at the call site. - The 1 KB cap is enforceable. The engine knows the maximum size from your schema and refuses oversized writes.
- Type stability. Each key has a fixed type (
numberorstring), so the TypeScript castdata.tapCount as numberis 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 GameController → CloudCounter:
- 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: 0to debounce double-clicks). Match the type rules (numberorstringonly) and keep total size under 1 KB. - Replace the tap target. The hit-test uses
APJS.TouchUtils.isScreenPointOnImageagainst the Image on the wired SceneObject — drop in any Screen Image withinteractable: true. - Reset on tap-and-hold. Subscribe to
APJS.TouchPhase.Endedand measure the elapsed time; on a long press, callcloudData.saveData({ tapCount: 0 })to reset.
- Add a key to the schema to persist more state (e.g. a
Suggestions for further play:
- Add a
lastUsageDayskey to the schema, snapshot it on every save, and detect "the user just rolled into a new calendar day" by comparinglastUsageDaysagainstuserUsageDayson 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.CloudDataManager—new CloudDataManager(schema),loadData(onSuccess, onError),saveData(partial, onSuccess?, onError?), plus thegetEffectUsageInfo()accessor.- Schema semantics — every key declared with a default; only
numberandstringtypes; total ≤ 1 KB. - Partial saves —
saveData({ x: 1 })retains the previously- persisted value of every other key. APJS.EffectUsageInfo—userUsageDays, 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.