Skip to main content

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:

  1. Renders JSX into the engine's UI layer.
  2. Re-renders on demand with this.rerender().

Heads-up
• The file must be saved as .tsx.
• The UIBehavior must be attached to a UILayer or UIPanel 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 / HookPurpose
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:

ApproachWhen to useHow to opt in/out
UncontrolledSimple 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" …>
ControlledInputs 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 behavior
  • this.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

HelperUse-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

SymptomLikely cause / fix
UI never appearsMake sure to attach the UIBehavior to a UILayer or UIPanel entity
Changes not reflected visuallyForgot to call this.rerender() after updating internal state
Buttons / inputs not clickableEnsure your container has pointer-events: auto in its style
Rendering issuesDon't use React fragments (<> </>).

Next Steps

  • Learn how to sync data between clients.
  • Dive into Behaviors to add game logic that talks to your UI.