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.

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 growingsizeDelta.xfills 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
GameControllerempty SceneObject hostingHudToolkit— the script that drives all the runtime updates.
Open the demo
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.textis 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,opacityare similarly direct setters.Image.coloris aColor(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.opacityis a separate 0-1 setter that multiplies on top ofcolor.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.anchoredPositionis 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.sizeDeltais the layout-space pixel size. The progress fill animation works by mutatingsizeDelta.xon every tick — combined withpivot: (0, 0.5)so the bar grows rightward instead of from its center.ScreenTransform.pivotmoves the rotation/scale anchor inside the rectangle. Settingpivot.x = 0puts 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 GameController → HudToolkit:
pointsPerTap(default1) — score increment per button press.bonusOnFull(default10) — score awarded each time the bar fills.fillPerTap(default60) — pixels added to the fill per tap. Lower for slower bars, higher for faster.fillMaxWidth(default600) — the bar's full-width pixel size. Must matchProgressBg'sScreenTransform.sizeDelta.xso 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'sImage.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'stextureslot and setstretchModeto"Fit"). - Animate a heart's loss with a quick scale tween via
ScreenTransform.scalebefore the color swap. - Add a 4th HUD element — a "Best" display fed by a
CloudDataManagerso the high score persists between sessions (see the Cloud Data tutorial for the schema + load/save pattern).
What you learned
This tutorial used:
Image—color,opacity,stretchMode, runtime mutation.Text—textsetter for live HUD updates,fontSize,bold,color.ScreenTransform—anchoredPosition,sizeDelta,pivot. The pivot trick (pivot.x = 0) is the key to a left-fill progress bar.APJS.TouchUtils.isScreenPointOnImagefor 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 inonUpdate'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.