User Interface
Dreamlab's UI system lets you author fully-interactive game interfaces—HUDs, menus, inventory screens—directly in TypeScript inside your game project.
Every interface lives in a UIBehavior
that:
- Renders JSX into the engine's UI layer.
- Re-renders on demand with
this.rerender()
.
Heads-up
• The file must be saved as.tsx
.
• TheUIBehavior
must be attached to aUILayer
orUIPanel
entity—any other host entity will throw.
• Don't use React fragments (<> </>
).
1. Anatomy of a UIBehavior
import { UIBehavior } from "@dreamlab/engine";
export default class MyUI extends UIBehavior {
override onInitialize() {
super.onInitialize();
if (!this.game.isClient()) return;
}
// called every simulation tick
override onTick() {
if (!this.game.isClient()) return;
/* game logic here, then call this.rerender() if state changes */
}
// called every animation frame
override onFrame() {
if (!this.game.isClient()) return;
/* lightweight per‑frame updates */
}
override render() {
return <div>Hello world!</div>;
}
}
Key pieces
Member / Hook | Purpose |
---|---|
render() | Pure function that returns JSX. |
this.rerender() | Schedule a call to render() on the next frame. Call it after any state change. |
onTick() / onFrame() | Per-tick (logic) and per-frame (visual) hooks. Update state here; call rerender() if needed. |
this.setCss() | Inject a <style> tag for larger style sheets (see §4). |
this.hide() / this.show() | Detach / reattach the rendered tree without destroying it (see §5). |
2. Example - Players Connected Overlay
import { PlayerJoined, PlayerLeft, UIBehavior } from "@dreamlab/engine";
/**
* Overlay that lists online players by nickname.
*/
export default class OnlinePlayersUI extends UIBehavior {
/** Map<connectionId, nickname> */
private players = new Map<string, string>();
override onInitialize() {
super.onInitialize();
if (!this.game.isClient()) return;
/* seed with existing connections */
for (const conn of this.game.network.connections) {
this.players.set(conn.id, conn.nickname ?? conn.id);
}
this.rerender(); // paint with the seeded list
/* join / leave listeners */
this.listen(this.game, PlayerJoined, ({ connection }) => {
if (!this.players.has(connection.id)) {
this.players.set(connection.id, connection.nickname ?? connection.id);
this.rerender(); // rerender() UI when a new player joins
}
});
this.listen(this.game, PlayerLeft, ({ connection }) => {
if (this.players.delete(connection.id)) this.rerender();
});
}
override render() {
return (
<div
style={{
position: "absolute",
top: "1rem",
right: "1rem",
padding: "0.75rem 1rem",
background: "rgba(0,0,0,0.6)",
borderRadius: "0.5rem",
color: "#fff",
fontFamily: "\"Inter\", sans-serif",
minWidth: "200px",
}}
>
<strong>Players Online ({this.players.size})</strong>
<ul style={{ margin: "0.5rem 0 0", padding: 0, listStyle: "none" }}>
{Array.from(this.players.values()).map(name => (
<li style={{ fontSize: "0.9rem" }}>
{name}
</li>
))}
</ul>
</div>
);
}
}
3. Controlled vs Uncontrolled Inputs
Dreamlab supports both patterns:
Approach | When to use | How to opt in/out |
---|---|---|
Uncontrolled | Simple fields where the browser keeps state. Query the DOM when you need the value. | Add data-uncontrolled="true" to the element, e.g. <input data-uncontrolled="true" …> |
Controlled | Inputs whose value affects other UI. Store the value on this and call rerender() . | Manage value yourself and call rerender() whenever it changes. |
import { UIBehavior } from "@dreamlab/engine";
/**
* One uncontrolled field + one controlled field.
* ▸ Uncontrolled: browser owns state, we snapshot on button click.
* ▸ Controlled: UIBehavior owns state, rerenders on every keystroke.
*/
export default class InputDemoUI extends UIBehavior {
/* controlled state */
name = "Alice";
/* latest snapshot from the uncontrolled input */
ucSnapshot = "";
/** grab value from uncontrolled node and paint it */
private snapshot() {
const el = this.uiElement?.querySelector<HTMLInputElement>("#uc");
if (el) {
this.ucSnapshot = el.value;
this.rerender(); // show the new snapshot
}
}
override render() {
return (
<div style={{ padding: "1rem", fontFamily: "Inter, sans-serif" }}>
{/* --- uncontrolled --- */}
<h4>Uncontrolled</h4>
<input
id="uc"
data-uncontrolled="true"
placeholder="type something" />
<button onClick={() => this.snapshot()}>Show</button>
<span> → {this.ucSnapshot || "?"}</span>
{/* --- controlled --- */}
<h4 style={{ marginTop: "1rem" }}>Controlled</h4>
<input
value={this.name}
onInput={e => {
this.name = (e.target as HTMLInputElement).value;
this.rerender(); // keep UI in sync every keystroke
}}
/>
<span> → {this.name}</span>
</div>
);
}
}
4. Styling
Because UI renders straight to the DOM, you can style it just like any web app:
- Inline styles - Quick & scoped
<style>
tags - Scoped to the behaviorthis.setCss()
- Best for larger stylesheets
Inline styles (quickest)
Use inline styles when it's a small, local tweak:
render() {
return (
<div style={{ background: "black", color: "white", padding: "1rem" }}>
Hello!
</div>
);
}
<style>
tag inside JSX
For more readable and scoped component styles:
render() {
const styles = `
.box {
background: #f7fafc;
padding: 1rem;
border-radius: 0.5rem;
}
button {
background: #4299e1;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
}
`;
return (
<div className="box">
<style>{styles}</style>
<p>Hello!</p>
<button>Click me</button>
</div>
);
}
this.setCss()
for larger styles
Set a persistent style sheet during onInitialize
:
onInitialize() {
super.onInitialize();
if (!this.game.isClient()) return;
this.setCss(`
#inventory {
display: grid;
gap: 0.5rem;
}
.slot {
border: 2px solid white;
padding: 1rem;
}
`);
}
5. Importing Fonts
There are two ways to import custom fonts into your UI. We recommend using the custom CSS file approach for clarity and reusability.
Option 1: Preferred - custom.css
at project root
Create a custom.css
file at the root of your game project and include the font import:
/* custom.css */
@import url('https://fonts.googleapis.com/css2?family=Playwrite+HU:[email protected]&display=swap');
Then reference the font in your styles:
render() {
return (
<div style={{ fontFamily: '"Playwrite HU", sans-serif' }}>
Hello with custom font!
</div>
);
}
Option 2: Load font dynamically in onInitialize()
If you don’t want to use a separate CSS file, you can append a <link>
directly to <head>
:
onInitialize() {
super.onInitialize?.();
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap";
document.head.appendChild(link);
}
And then use it like:
render() {
return (
<p style={{ fontFamily: '"Press Start 2P", cursive' }}>
Retro vibes!
</p>
);
}
6. Utilities
Helper | Use-case |
---|---|
this.hide() | Temporarily remove the UI from the DOM (no destroy) |
this.show() | Re-attach after hide() and rerender |
this.setCss() | Inject or update component-scoped CSS |
7. Troubleshooting
Symptom | Likely cause / fix |
---|---|
UI never appears | Make sure to attach the UIBehavior to a UILayer or UIPanel entity |
Changes not reflected visually | Forgot to call this.rerender() after updating internal state |
Buttons / inputs not clickable | Ensure your container has pointer-events: auto in its style |
Rendering issues | Don't use React fragments (<> </> ). |