Skip to main content

2D / UI: HUD Toolkit

A live HUD with a tappable button. Each tap awards points and fills a progress bar; filling the bar grants a 10-point bonus and costs one life; losing all three lives drops into a "tap anywhere to retry" game-over state. Demonstrates the full 2D-UI primitive set — Image color and opacity, Text runtime updates, ScreenTransform for anchored layout and sizeDelta-driven progress animation, and TouchUtils.isScreenPointOnImage for button hit-testing.

HUD Toolkit demo running in Effect House preview

What you'll build

  • Four 2D Text labels — title, big yellow score counter, status hint at the bottom, and (as a child of the button) a "+1" label.
  • Three Screen Image life hearts in a row, alive in red and dimming to grey when lost.
  • A progress bar = a dark Screen Image background plus a green fill Screen Image whose ScreenTransform.pivot.x = 0 (left-anchored) so growing sizeDelta.x fills the bar rightward.
  • A blue tap button (Screen Image) hit-tested with APJS.TouchUtils.isScreenPointOnImage, and an opacity dim on the button when game-over is active.
  • A GameController empty SceneObject hosting HudToolkit — the script that drives all the runtime updates.

Open the demo

↓ hud-toolkit.zip

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

  • Camera — default 3D perspective camera, untouched (renders the camera feed behind everything).
  • 2D Camera — auto-created when the first 2D Text was added. Parents the entire HUD.
  • TitleText — "HUD Toolkit", anchored at (0, 480), fontSize 40.
  • ScoreText — "Score: 0", anchored at (0, 380), fontSize 64, bold, yellow.
  • LifeHeart0 / LifeHeart1 / LifeHeart2 — 60 × 60 red Screen Images at (-80, 270), (0, 270), (80, 270).
  • ProgressBg — dark slate Screen Image at (0, 80), sizeDelta (600, 40) — the bar background.
  • ProgressFill — green Screen Image at (-300, 80), sizeDelta (0, 36), pivot: (0, 0.5) so the fill grows rightward from the bar's left edge.
  • TapButton — blue Screen Image at (0, -120), sizeDelta (320, 140).
  • ButtonLabel — child of TapButton, "+1" white bold text.
  • StatusText — "Tap to score — fill the bar to bonus +10", fontSize 28, anchored at (0, -340).
  • GameController — empty SceneObject hosting HudToolkit.

Read the script

HudToolkit.ts

@component()
export class HudToolkit extends APJS.BasicScriptComponent {
// The 2D Text labels updated each frame.
@serializeProperty scoreText!: APJS.SceneObject;
@serializeProperty statusText!: APJS.SceneObject;

// The button image hit-tested via TouchUtils.isScreenPointOnImage.
@serializeProperty tapButton!: APJS.SceneObject;

// Lives indicator — ordered left-to-right; lost lives turn dim grey.
@serializeProperty lifeHearts: APJS.SceneObject[] = [];

// Progress bar fill — sizeDelta.x grows from 0 to fillMaxWidth.
@serializeProperty progressFill!: APJS.SceneObject;

// Tunables.
@serializeProperty pointsPerTap: number = 1;
@serializeProperty bonusOnFull: number = 10;
@serializeProperty fillPerTap: number = 60; // px added per tap
@serializeProperty fillMaxWidth: number = 600; // matches the bar background's sizeDelta.x

// Runtime state.
private scoreComp!: APJS.Text;
private statusComp!: APJS.Text;
private buttonImage!: APJS.Image;
private heartImages: APJS.Image[] = [];
private fillST!: APJS.ScreenTransform;
private touchCallback!: (e: APJS.IEvent) => void;

private score: number = 0;
private lives: number = 3;
private fillPx: number = 0;
private gameOver: boolean = false;
private inited: boolean = false;

// Lazy init in onUpdate — @serializeProperty refs are null in the very
// first onStart frame; we wait until they're populated.
onUpdate(_dt: number): void {
if (this.inited) return;
if (
!this.scoreText || !this.statusText || !this.tapButton ||
!this.progressFill || !this.lifeHearts || this.lifeHearts.length === 0
) return;

this.scoreComp = this.scoreText.getComponent("Text") as APJS.Text;
this.statusComp = this.statusText.getComponent("Text") as APJS.Text;
this.buttonImage = this.tapButton.getComponent("Image") as APJS.Image;
this.fillST = this.progressFill.getComponent("ScreenTransform") as APJS.ScreenTransform;

// Cache each heart's Image component once. The order matches the
// wiring order in the inspector — hearts[0] is the leftmost life.
for (const obj of this.lifeHearts) {
if (!obj) continue;
const img = obj.getComponent("Image") as APJS.Image;
if (img) this.heartImages.push(img);
}

this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase !== APJS.TouchPhase.Began) return;
this.handleTap(t.position);
};
APJS.EventManager.getGlobalEmitter()
.on(APJS.EventType.Touch, this.touchCallback, this);

this.refreshHUD();
this.inited = true;
console.log("[HudToolkit] ready — lives=" + this.lives + " fillMaxWidth=" + this.fillMaxWidth);
}

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

private handleTap(pos: APJS.Vector2f): void {
// Game-over state: any tap (anywhere on screen) restarts.
if (this.gameOver) {
this.resetGame();
return;
}

// TouchUtils.isScreenPointOnImage hit-tests the normalized touch
// position against the Image's rendered bounds. Pass touch.position
// directly — no axis remapping needed.
if (!APJS.TouchUtils.isScreenPointOnImage(pos, this.buttonImage)) return;

// Active tap: +pointsPerTap, advance the fill.
this.score += this.pointsPerTap;
this.fillPx += this.fillPerTap;

// Bar full — award bonus, lose a life, reset the fill.
if (this.fillPx >= this.fillMaxWidth) {
this.fillPx = 0;
this.score += this.bonusOnFull;
this.lives -= 1;
if (this.lives <= 0) {
this.lives = 0;
this.enterGameOver();
}
}

this.refreshHUD();
}

private refreshHUD(): void {
this.scoreComp.text = "Score: " + this.score;

// Each heart image goes red while alive, dim-grey once lost. We mutate
// Image.color directly each refresh — cheap, and the visual contrast
// makes the loss state instantly readable.
for (let i = 0; i < this.heartImages.length; i++) {
const alive = i < this.lives;
this.heartImages[i].color = alive
? new APJS.Color(0.95, 0.32, 0.32, 1)
: new APJS.Color(0.32, 0.34, 0.38, 1);
}

// Animate the fill bar by mutating ScreenTransform.sizeDelta.x. With
// pivot.x = 0 (left-anchored) the bar grows rightward from its
// anchored-position edge.
const sd = this.fillST.sizeDelta;
this.fillST.sizeDelta = new APJS.Vector2f(this.fillPx, sd.y);

if (!this.gameOver) {
const pct = Math.min(100, Math.round((this.fillPx / this.fillMaxWidth) * 100));
this.statusComp.text = "Tap the button — progress " + pct + "%";
}
}

private enterGameOver(): void {
this.gameOver = true;
// Visually mute the button so the lost state is obvious.
this.buttonImage.opacity = 0.4;
this.statusComp.text = "Game Over — tap anywhere to retry";
console.log("[HudToolkit] game over at score " + this.score);
}

private resetGame(): void {
this.score = 0;
this.lives = 3;
this.fillPx = 0;
this.gameOver = false;
this.buttonImage.opacity = 1;
this.refreshHUD();
console.log("[HudToolkit] reset");
}
}

The 2D / UI calls of interest:

  • Text.text is a plain string setter — assign once per HUD refresh. The label re-renders with the new content, no re-layout work needed. fontSize, bold, color, opacity are similarly direct setters.
  • Image.color is a Color (RGBA, 0-1) setter that re-tints the rendered sprite in place. Used here to swap each heart between red (alive) and dim grey (lost) on every refresh — cheap enough to do per tap.
  • Image.opacity is a separate 0-1 setter that multiplies on top of color.a. Useful when you want to mute a button visually without changing its hue (game-over state).
  • Image.stretchMode = "Stretch" is what you want for solid-color panels. The default "Fit" preserves the texture's aspect ratio, which causes a 1×1 white texture to render as a tiny square inside whatever sizeDelta you set. This is the most common 2D-UI gotcha.
  • ScreenTransform.anchoredPosition is the layout-space pixel position relative to the anchor. With the default anchor (0.5, 0.5) (center), (-300, 80) reads as "300 px left of center, 80 px above".
  • ScreenTransform.sizeDelta is the layout-space pixel size. The progress fill animation works by mutating sizeDelta.x on every tick — combined with pivot: (0, 0.5) so the bar grows rightward instead of from its center.
  • ScreenTransform.pivot moves the rotation/scale anchor inside the rectangle. Setting pivot.x = 0 puts the anchor at the rectangle's left edge — exactly what a "fills from the left" progress bar needs.
  • APJS.TouchUtils.isScreenPointOnImage(touchPos, image) does the hit-test for you — accepts the raw normalized touch position (touchInfo.position) and the Image component, returns boolean. No coordinate conversion or anchor math needed; the helper handles every layout case.

Customize

On GameControllerHudToolkit:

  • pointsPerTap (default 1) — score increment per button press.
  • bonusOnFull (default 10) — score awarded each time the bar fills.
  • fillPerTap (default 60) — pixels added to the fill per tap. Lower for slower bars, higher for faster.
  • fillMaxWidth (default 600) — the bar's full-width pixel size. Must match ProgressBg's ScreenTransform.sizeDelta.x so the green fill aligns with the dark background.

Tunables in the editor (no script changes needed):

  • Each heart's Image.color — drop in any color; the script overrides it on every refresh, so the inspector value is just the initial state.
  • TapButton's Image.color — change the button hue; the game-over opacity dim still applies on top.
  • Any 2D Text's fontSize / bold / letterSpacing / lineSpacing — the script only sets .text; everything else is yours.

Suggestions for further play:

  • Replace each life heart's solid-color square with a heart Texture (drag a PNG into the Image's texture slot and set stretchMode to "Fit").
  • Animate a heart's loss with a quick scale tween via ScreenTransform.scale before the color swap.
  • Add a 4th HUD element — a "Best" display fed by a CloudDataManager so the high score persists between sessions (see the Cloud Data tutorial for the schema + load/save pattern).

What you learned

This tutorial used:

  • Imagecolor, opacity, stretchMode, runtime mutation.
  • Texttext setter for live HUD updates, fontSize, bold, color.
  • ScreenTransformanchoredPosition, sizeDelta, pivot. The pivot trick (pivot.x = 0) is the key to a left-fill progress bar.
  • APJS.TouchUtils.isScreenPointOnImage for button hit-testing — the helper that makes Screen Image buttons just work without manual coordinate math.
  • @serializeProperty SceneObject[] for the lives row — wired in the inspector as a list, then mapped to component references in onUpdate's lazy init.

Read the full Image reference, the Text reference, the ScreenTransform reference, and the 2D / UI namespace overview.

For touch hit-testing, see TouchUtils and TouchData. For solid-color Screen Images and other UI patterns, see the workspace's skills/UIPatterns/ guide.

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