Nameplates#
In this tutorial we take a working 3D scene and give it a UI layer: every wandering ghost gets a nameplate that follows it around the screen. The 3D side is already built when we start. The tutorial is about the JavaScript side, which is where you spend your time in a real OneJS project: standing up a JSRunner project, iterating with live reload, spawning the crowd from JS, and projecting world positions into panel space with RuntimePanelUtils.
The models come from the free Ghost character asset by SigmoidButton. Any character prefab works; nothing below depends on the ghosts specifically.
Every bullet point is an actionable step. Everything else is explanation.
The starting point#
The scene itself is plain Unity, nothing OneJS about it: a ground tiled from the asset's Block prefab, a camera aimed down at the field, and a GhostAgent prefab (the asset's Ghost_animation model plus an Animator playing its idle clip). Character movement is C#'s job, so the prefab also carries this wander script:
using UnityEngine;
public class RoamingGhost : MonoBehaviour {
[SerializeField] float _minSpeed = 0.8f;
[SerializeField] float _maxSpeed = 2.2f;
[SerializeField] float _turnSpeed = 80f;
[SerializeField] float _roamRadius = 7f;
[SerializeField] float _bobAmplitude = 0.3f;
[SerializeField] float _bobFrequency = 0.4f;
Vector3 _home;
Vector3 _flatPos;
Vector3 _target;
float _yaw;
float _speed;
float _phase;
void Start() {
_home = transform.position;
_flatPos = _home;
_yaw = transform.eulerAngles.y;
_speed = Random.Range(_minSpeed, _maxSpeed);
_phase = Random.value * Mathf.PI * 2f;
PickTarget();
}
void PickTarget() {
var dir = Random.insideUnitCircle.normalized * Random.Range(2f, _roamRadius);
_target = new Vector3(_home.x + dir.x, _home.y, _home.z + dir.y);
}
void Update() {
float dt = Time.deltaTime;
var to = _target - _flatPos;
to.y = 0f;
if (to.sqrMagnitude < 0.25f) { PickTarget(); to = _target - _flatPos; to.y = 0f; }
float targetYaw = Mathf.Atan2(to.x, to.z) * Mathf.Rad2Deg;
_yaw = Mathf.MoveTowardsAngle(_yaw, targetYaw, _turnSpeed * dt);
_flatPos += Quaternion.Euler(0f, _yaw, 0f) * Vector3.forward * (_speed * dt);
float bob = Mathf.Sin(Time.time * _bobFrequency * Mathf.PI * 2f + _phase) * _bobAmplitude;
transform.SetPositionAndRotation(_flatPos + Vector3.up * bob, Quaternion.Euler(0f, _yaw, 0f));
}
}Each instance glides toward random points near its spawn, turning smoothly so paths curve, with a bob on top. That is the whole C# footprint. Everything from here on is JavaScript.
Add a JSRunner#
- Create an empty GameObject named
Appand add the JSRunner component to it. - Click Initialize Project in the inspector.
That one click scaffolds a complete project next to your scene:
{SceneFolder}/{SceneName}/App/
├── ~/ # your TypeScript source (hidden from Unity)
│ ├── index.tsx # entry point
│ └── package.json, esbuild.config.mjs, tsconfig.json, ...
├── PanelSettings.asset # the project marker JSRunner reads
└── app.js.txt # the built bundleIt also runs npm install and npm run build for you. When the bundle exists, JSRunner renders it in the Game view without entering Play mode. This edit-mode preview plus the build watcher is the core loop:
- Open
~/index.tsx(the inspector's Open in Code Editor button works). - Change the scaffolded text, save, and watch the Game view update in about a second.
You never trigger builds by hand while iterating. The editor manages an esbuild watcher per JSRunner project, and JSRunner hot-reloads whenever the bundle changes, in both edit mode and Play mode. Here is that loop against this tutorial's finished UI: change one value, save, and the running scene restyles in place.
Hand the prefab to JS#
JS decides which ghosts exist, so it needs the prefab:
- On the JSRunner, add a row to the Globals list: key
ghostPrefab, value theGhostAgentprefab. - Declare it for TypeScript in
~/types/app.d.ts:
declare const ghostPrefab: CS.UnityEngine.GameObjectAnything in the Globals list is injected as a global before your bundle runs. It is the standard way to hand scene assets (prefabs, materials, shaders) across without string lookups.
Spawn ghosts in onPlay#
The roster is just an array of strings. Replace ~/index.tsx with:
import { useEffect, useRef, useState } from "react"
import { render, View, Text, type VisualElement } from "onejs-react"
import { Camera, GameObject, Quaternion, Vector3 } from "UnityEngine"
import * as Unity from "UnityEngine"
import { RuntimePanelUtils } from "UnityEngine/UIElements"
const UnityObject = Unity.Object
const GHOST_NAMES = [
"Boo", "Wisp", "Echo", "Shade", "Mist", "Flick", "Gloom", "Puff",
"Haze", "Murk", "Specter", "Banshee", "Casper", "Vapor", "Grim",
"Sprite", "Umbra", "Wraith", "Phantom", "Whisper",
// ...as many as you like
]
type Ghost = { name: string, tf: CS.UnityEngine.Transform }
let ghostRoot: CS.UnityEngine.GameObject | null = null
let ghosts: Ghost[] = []
let listeners: ((g: Ghost[]) => void)[] = []
function setGhosts(list: Ghost[]) {
ghosts = list
for (const l of listeners) l(list)
}
function useGhosts(): Ghost[] {
const [list, setList] = useState(ghosts)
useEffect(() => {
listeners.push(setList)
return () => { listeners = listeners.filter(l => l !== setList) }
}, [])
return list
}
export function onPlay() {
const root = new GameObject("Ghosts")
ghostRoot = root
const list: Ghost[] = []
for (const name of GHOST_NAMES) {
const go = UnityObject.Instantiate(ghostPrefab, root.transform) as CS.UnityEngine.GameObject
go.name = name
const t = go.transform
t.position = new Vector3(Math.random() * 38 - 19, 1.1, Math.random() * 32 - 5)
t.rotation = Quaternion.Euler(0, Math.random() * 360, 0)
list.push({ name, tf: t })
}
setGhosts(list)
}
export function onStop() {
if (ghostRoot) {
UnityObject.Destroy(ghostRoot)
ghostRoot = null
}
setGhosts([])
}A few things worth knowing:
- Imports from
"UnityEngine"are rewritten to theCS.*interop layer at build time.Objectis star-imported because plainObjectwould collide with the JavaScript builtin. - Module-level code (including the
render()call we add next) runs in edit-mode preview and in Play mode.onPlayandonStoprun only around Play mode, which is exactly where GameObject spawning belongs. onStopalso runs before a hot reload during Play, and OneJS's Janitor destroys JS-created GameObjects on reload as a safety net. Either way, a reload starts clean andonPlayrespawns the crowd.- The tiny
useGhostssubscription is how a plain lifecycle function hands data to React without rendering anything itself.
Enter Play mode: ghosts spawn, wander under their C# brains, and clean up when you exit.
Project nameplates onto the panel#
RuntimePanelUtils.CameraTransformWorldToPanel(panel, worldPos, camera) converts a world position into panel coordinates. Call it once per ghost per frame and move a label to the result. Add the overlay to the same file, above the lifecycle exports:
const PLATE_WIDTH = 90
const PLATE_LIFT = 34 // px above the ghost's projected center
const EDGE_MARGIN = 60 // px past the edge before a plate counts as out of view
function Nameplates() {
const roster = useGhosts()
const refs = useRef<(VisualElement | null)[]>([])
const shown = useRef<boolean[]>([])
const [inView, setInView] = useState(0)
useEffect(() => {
if (roster.length === 0) return
shown.current = []
let cam: CS.UnityEngine.Camera | null = null
let panelW = 0, panelH = 0
let frame = 0, lastCount = -1
let raf = 0
const tick = () => {
raf = requestAnimationFrame(tick)
const panel = __root.panel
if (!panel) return
if (frame % 30 === 0) {
if (!cam) cam = Camera.main
const wb = __root.worldBound
if (!Number.isNaN(wb.width) && wb.width > 0) {
panelW = wb.width
panelH = wb.height
}
}
frame++
if (!cam || panelW === 0) return
let count = 0
for (let i = 0; i < roster.length; i++) {
const el = refs.current[i]
if (!el) continue
const p = RuntimePanelUtils.CameraTransformWorldToPanel(panel, roster[i].tf.position, cam)
const visible = p.x > -EDGE_MARGIN && p.x < panelW + EDGE_MARGIN
&& p.y > -EDGE_MARGIN && p.y < panelH + EDGE_MARGIN
if (visible !== shown.current[i]) {
shown.current[i] = visible
el.style.opacity = visible ? 1 : 0
}
if (visible) {
count++
el.style.left = p.x - PLATE_WIDTH / 2
el.style.top = p.y - PLATE_LIFT
}
}
if (frame % 15 === 0 && count !== lastCount) {
lastCount = count
setInView(count)
}
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [roster])
return (
<View pickingMode="Ignore" style={{ position: "absolute", left: 0, top: 0, right: 0, bottom: 0 }}>
{roster.map((g, i) => (
<View
key={i}
ref={(el: VisualElement | null) => { refs.current[i] = el }}
pickingMode="Ignore"
style={{
position: "absolute",
width: PLATE_WIDTH,
alignItems: "center",
paddingTop: 2,
paddingBottom: 3,
backgroundColor: "rgba(14, 14, 30, 0.82)",
borderRadius: 9,
borderWidth: 1,
borderColor: "rgba(167, 139, 250, 0.4)",
opacity: 0,
}}
>
<Text pickingMode="Ignore" text={g.name} style={{ fontSize: 11, color: "#e9e4ff" }} />
</View>
))}
<View pickingMode="Ignore" style={{ position: "absolute", left: 12, top: 12, padding: 10, backgroundColor: "rgba(14, 14, 30, 0.82)", borderRadius: 8 }}>
<Text pickingMode="Ignore" text="Ghost Field" style={{ fontSize: 14, color: "#e9e4ff" }} />
<Text
pickingMode="Ignore"
text={__isPlaying ? `${inView} of ${roster.length} in view` : "Enter Play mode to spawn ghosts"}
style={{ fontSize: 11, marginTop: 2, color: "#a7a0d2" }}
/>
</View>
</View>
)
}
render(<Nameplates />, __root)The moving parts:
- Imperative positioning. The
requestAnimationFrameloop writesstyle.left/style.topstraight onto the elements through refs. Going through React state instead would re-render the whole overlay sixty times a second; on the QuickJS interpreter that is money you do not have. React renders exactly twice here: once at startup and once when the roster changes. - In-view culling. A plate whose projection falls past the panel edge (plus a margin) is hidden by toggling opacity, and the toggle only crosses into C# when the visibility actually changes. The counter in the corner updates through normal React state, throttled to every 15 frames.
pickingMode="Ignore"keeps the whole overlay transparent to input, so clicks still reach whatever is underneath.- Plates start at
opacity: 0and the first loop tick reveals them, so nothing flashes at the top-left corner before the first projection lands.
The in-view logic up close: a wide wanderer drifts past the edge, its plate drops with it, and the counter follows.
Save the file. In Play mode you get the result from the top of the page: a wandering crowd, each ghost tagged, plates dropping out as ghosts drift off screen.
Where to go next#
- Push C# state into React the idiomatic way: State Sync
- What the
CS.*layer can reach and what it costs: C# Interop - When per-frame interop gets hot: Zero-Alloc Interop