HUD and text
The HUD is made of ordinary game objects that draw DOM elements on top of the canvas. That means any font, any character (emoji included), crisp text at any resolution, and full CSS styling when you want it. There are two components, text() and uiRect(), and two helpers that place them, fixed() and anchor().
import threeplay from "threeplay";
threeplay({ width: 960, height: 540, background: "#1b1b2f" });
scene("main", () => { add([plane(30), color("#33415c")]);
const enemy = add([capsule(0.4, 1.6), pos(0, 0.8, -4), color("crimson"), health(5), "enemy"]); // No fixed(): this label floats over the enemy's position in the world. enemy.add([text("Goblin", { size: 14, weight: "bold" }), pos(0, 1.3, 0), anchor("bot")]);
// Fixed to the screen: pos is in logical pixels from the top-left corner. const score = add([text("Score: 0", { size: 24 }), pos(20, 16), fixed()]); add([text("HP", { size: 16 }), pos(20, 54), fixed()]); add([uiRect(204, 20, { radius: 5 }), color("#111"), pos(50, 52), fixed()]); // frame const bar = add([uiRect(200, 16, { radius: 3 }), color("limegreen"), pos(52, 54), fixed()]); add([ text("Press space to hurt the goblin", { size: 20, align: "center" }), pos(width() / 2, height() - 20), anchor("bot"), fixed(), ]);
let points = 0; enemy.onHurt(() => { points += 10; score.text = `Score: ${points}`; bar.width = 200 * (enemy.hp / enemy.maxHp); // setters update the element }); enemy.onDeath(() => enemy.destroy()); onButtonPress("jump", () => enemy.exists() && enemy.hurt(1));});
go("main");text()
Section titled “text()”const label = add([text("Hello", { size: 24, weight: "bold" }), pos(20, 20), fixed()]);
label.text = "Score: 10"; // changes the text (numbers work too)label.textSize = 32;| Option | Default | Meaning |
|---|---|---|
size |
16 |
Font size, in logical pixels. |
font |
system-ui, sans-serif |
Any CSS font family. |
width |
none | Wrap the text at this width, in logical pixels. Without it the text stays on the lines you wrote (\n works). |
align |
"left" |
"left", "center" or "right" (matters for several lines). |
weight |
"normal" |
"normal", "bold" or a number such as 600. |
outline |
true |
A dark outline and shadow so the text reads over any 3D background. |
Text is white. Use color() to change it: add([text("Danger", { size: 30 }), color("red"), pos(20, 60), fixed()]). text() accepts a string or a number.
uiRect()
Section titled “uiRect()”A rectangle for bars, panels and backgrounds. It is white until you give it a color().
const panel = add([uiRect(300, 120, { radius: 8, outline: "#fff" }), color("#000"), opacity(0.6), pos(20, 20), fixed()]);panel.width = 400;Options are radius (corner radius in pixels) and outline (a CSS color for a 2 px border). Both width and height are fields you can assign.
fixed(): screen space or world space
Section titled “fixed(): screen space or world space”- With
fixed(), the element sits atpos(x, y): logical pixels from the top-left corner. Thezis ignored, andpos()is optional (the default is(0, 0)). - Without
fixed(), the element floats over the object’s world position, projected onto the screen every frame. It hides when the position is behind the camera. Walls do not hide it.
World-space labels are the easy way to put a name or an icon over a 3D thing. Make them children of the object so they move with it, and give them a local pos to lift them up:
enemy.add([text("!", { size: 30 }), color("yellow"), pos(0, 1.5, 0), anchor("bot")]);When the object is destroyed, so is its label.
anchor()
Section titled “anchor()”anchor(a) says which point of the element pos refers to. The default is "topleft".
topleft top toprightleft center rightbotleft bot botrightadd([text("Paused", { size: 40 }), pos(480, 270), anchor("center"), fixed()]); // centered on the screenadd([text("Score: 0"), pos(width() - 20, 16), anchor("topright"), fixed()]); // flush rightAn unknown anchor name throws with the list of valid names.
Coordinates and screen size
Section titled “Coordinates and screen size”HUD coordinates are in logical pixels. With threeplay({ width: 960, height: 540 }) the screen is always 960 by 540 logical pixels, however big the window is, and everything scales together. Layouts are simple: pos(480, 270) is always the center.
Without width and height the game fills the window and the logical size is the window size in CSS pixels, which can change. Compute positions from width(), height() and center(), and re-place elements in onResize:
const score = add([text("Score: 0"), anchor("topright"), pos(width() - 16, 16), fixed()]);onResize(() => (score.pos.x = width() - 16));Following the object
Section titled “Following the object”Every frame, each element follows these fields of its object when the object has them:
pos(withfixed()), or the world position (without it);hidden, including thehiddenof any ancestor:enemy.hidden = truehides the label too;color, for the text color (or the fill of auiRect);opacity, sofadeOut()andopacity(0.5)work on HUD elements as they do on shapes.
Styling with obj.el
Section titled “Styling with obj.el”obj.el is the DOM element (null when running headless). Style it like any element:
const title = add([text("GAME OVER", { size: 48, font: "Impact, sans-serif" }), pos(480, 200), anchor("center"), fixed()]);Object.assign(title.el.style, { letterSpacing: "0.1em", textShadow: "0 0 12px #f00", padding: "8px 16px", background: "rgba(0,0,0,.5)", borderRadius: "8px",});title.el.classList.add("my-title");The HUD owns a few styles and rewrites them when they change: transform and display always, and color / opacity when the object has color() / opacity(). Set those through the components instead of through el.style. Everything else is yours.
Custom fonts work the usual way: declare an @font-face in your page and pass its name in font.
Good to know
Section titled “Good to know”- HUD objects are normal game objects: they are tagged, found with
get(), paused, destroyed (the element is removed with them), and cleared bygo(). Create the HUD inside each scene. - Elements are created hidden and shown on the first frame, so nothing flashes at the top-left corner.
- Writes to the DOM are skipped when nothing changed, so a HUD with many static labels is cheap.
worldToHud(worldPoint)is the same astoScreen(worldPoint): it projects a 3D point to logical screen coordinates, or returnsnullwhen it is behind the camera.