Use the /collab command on Discord to gain access to the OneJS private repo. The repo offers early access to the latest features and fixes that may not yet be available on the Asset Store. An early preview of OneJS V2 is available on branch onejs-v2. It brings major performance improvements, zero-allocation interop, and a new esbuild workflow.
VERSION
Doc Menu

Tutorial 101

This is a step-by-step tutorial introducing many aspects of OneJS. Familiarity with Typescript, Preact, and Unity is recommended.

Tip: Every bullet point is an actionable step. Everything else are explanations.

If you haven't already, please:

Table of Contents

Setup

Unity

  • Start with an empty project and an empty scene (you can leave Main Camera and Directional Light alone).
  • Import OneJS from Package Manager (or directly clone the private repo if you have access).
  • Drag the ScriptEngine prefab onto the Hierarchy panel.
  • Go to Project Settings -> Player, and make sure "Run In Background" is turned on under Resolution and Presentation
  • [Optional] In Project Settings -> Player -> Other Settings, Change Color Space to Linear if it's not already.
  • [Optional] Turn on VSync in the Game panel to save some CPU cycles.
  • [Optional] Change the Main Camera's Clear Flags to Solid Color and change its background color to a more desaturated one i.e. #E0CFB9 (so that it won't get in the way of our UI later).
  • Finally, press Play to Enter Playmode (ScriptEngine will setup a bunch of stuff for the first time)

VSCode

  • Start VSCode and open folder {ProjectDir}/OneJS. (NOTE: {ProjectDir} is not your Assets folder; it is one level above the Assets folder)
  • Start tsc watchmode by pressing Ctrl + Shift + B or Cmd + Shift + B and choose tsc: watch - tsconfig.json
  • Create a new file index.tsx (This is the only .ts file we'll be working with for the rest of this tutorial)

A Ball on a Plane

Let's first spawn a single Sphere using Typescript.

  • Make sure Unity is still in Playmode
  • In index.tsx, enter the following and save the file:
import { GameObject, PrimitiveType } from "UnityEngine"

GameObject.CreatePrimitive(PrimitiveType.Sphere)

This creates a white Sphere in the scene.

After you save the file, OneJS will reload the script automatically (and take care of cleaning up any old GameObject created by the previous run).

And as you can see, the code is very similar to what you'd normally write in C#. OneJS provides a lot of TS type definitions for Unity. Alternatively, you can also just use plain JS without any Typing info:

importNamespace("UnityEngine").GameObject.CreatePrimitive(0)

Now let's tweak the Camera a little and add a plane.

  • Change your code to the following:
import { Camera, CameraClearFlags, GameObject, MeshRenderer, PrimitiveType, Vector3 } from "UnityEngine"
import { parseColor } from "onejs/utils/color-parser"

Camera.main.transform.position = new Vector3(8, 4, -8)
Camera.main.transform.LookAt(new Vector3(0, 1, 0))

const plane = GameObject.CreatePrimitive(PrimitiveType.Plane)
plane.GetComponent(MeshRenderer).material.color = parseColor("DarkGreen")

const sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere)
sphere.GetComponent(MeshRenderer).material.color = parseColor("FireBrick")
sphere.transform.position = new Vector3(0, 5, 0)

Make it Bounce

Let's tweak some physics stuff, add a Rigidbody to the sphere, and add a bouncy PhysicMaterial to both sphere and plane.

  • Append the following code:
Physics.gravity = new Vector3(0, -20, 0) // -9.8 is too "floaty", -20 makes things slightly more realistic
let rb = sphere.AddComponent(Rigidbody)
let pm = new PhysicMaterial()
pm.bounciness = 0.8
sphere.GetComponent(Collider).material = pm
plane.GetComponent(Collider).material = pm

Tip: Use the Quick Fix code action to easily import typings

Add Some UI

Let's add a button that resets the ball's position and changes its color when clicked.

  • Append the following code:
const App = () => {

    function onClick() {
        sphere.GetComponent(MeshRenderer).material.color = Random.ColorHSV(0, 1, 1, 1, 0.5, 1)
        sphere.transform.position = new Vector3(0, 5, 0)
        rb.velocity = new Vector3(0, 0, 0)
    }

    return <button onClick={onClick} text="Reset Ball" />
}

render(<App />, document.body)

Again, use the "Add all missing imports" code action to quickly take care of the imports.

As you can see, you can just seamlessly include a Preact component and render it.

Let's style the button using the Emotion api:

...

return <button class={emo`
            position: absolute;
            bottom: 0;
            left: 0;
            margin: 10px;
            border-width: 0;
            border-radius: 5px;
            color: white;
            background-color: rgb(42, 192, 197);
            transition: translate 0.2s, background-color 0.2s;
            &:hover {
                background-color: rgb(70, 162, 219);
                translate: 0 -5px;
            }
        `} onClick={onClick} text="Reset Ball" />

...

(You can also style your elements via inline styles, uss files, runtime uss strings, Tailwind classes, and Styled Components APIs.)

Click for Full Code
import { Camera, CameraClearFlags, Collider, GameObject, MeshRenderer, PhysicMaterial, Physics, PrimitiveType, Random, Rigidbody, Vector3 } from "UnityEngine"
import { parseColor } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { emo } from "onejs/styled"

Camera.main.transform.position = new Vector3(8, 4, -8)
Camera.main.transform.LookAt(new Vector3(0, 1, 0))

const plane = GameObject.CreatePrimitive(PrimitiveType.Plane)
plane.GetComponent(MeshRenderer).material.color = parseColor("DarkGreen")

const sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere)
sphere.GetComponent(MeshRenderer).material.color = parseColor("FireBrick")
sphere.transform.position = new Vector3(0, 5, 0)

Physics.gravity = new Vector3(0, -20, 0)
let rb = sphere.AddComponent(Rigidbody)
let pm = new PhysicMaterial()
pm.bounciness = 0.8
sphere.GetComponent(Collider).material = pm
plane.GetComponent(Collider).material = pm

const App = () => {

    function onClick() {
        sphere.GetComponent(MeshRenderer).material.color = Random.ColorHSV(0, 1, 1, 1, 0.5, 1)
        sphere.transform.position = new Vector3(0, 5, 0)
        rb.velocity = new Vector3(0, 0, 0)
    }

    return <button class={emo`
            position: absolute;
            bottom: 0;
            left: 0;
            margin: 10px;
            border-width: 0;
            border-radius: 5px;
            color: white;
            background-color: rgb(42, 192, 197);
            transition: translate 0.2s, background-color 0.2s;
            &:hover {
                background-color: rgb(70, 162, 219);
                translate: 0 -5px;
            }
        `} onClick={onClick} text="Reset Ball" />
}

render(<App />, document.body)

Time for Some Vector Action

Let's do a circular progress bar from scratch. UI Toolkit's Vector API makes this extremely easy.

  • Delete everything in index.tsx
  • Copy & paste the following
import { emo } from "onejs/styled"
import { parseColor } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { useRef, useEffect } from "preact/hooks"
import { Mathf, Vector2 } from "UnityEngine"
import { MeshGenerationContext, Angle, ArcDirection } from "UnityEngine/UIElements"

const RadialProgress = () => {
    const ref = useRef<Dom>();

    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
    }, [])

    function onGenerateVisualContent(mgc: MeshGenerationContext) {
        var painter2D = mgc.painter2D

        const resolvedStyle = ref.current.ve.resolvedStyle
        let radius = Mathf.Min(resolvedStyle.width, resolvedStyle.height) / 2
        let dx = resolvedStyle.width / 2 - radius
        let dy = resolvedStyle.height / 2 - radius

        painter2D.strokeColor = parseColor("#305fbc")
        painter2D.lineWidth = radius * 0.2
        painter2D.BeginPath()
        painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(360), ArcDirection.Clockwise)
        painter2D.Stroke()
        painter2D.ClosePath()
    }

    return <div ref={ref} class={emo`width:100%;height:100%;`}></div>
}

render(<RadialProgress />, document.body)

A bit to unpack here. useRef() gets a reference to the underlying DOM, while useEffect() here is used to run some startup code. These are called Hooks in Preact; you can get a primer on those over at preactjs.com.

ref.current.ve deserves some special attention here. The Dom object in OneJS is just a thin wrapper over the underlying VisualElement (which is the UI Toolkit's equivalent of HTMLElement). Dom.ve returns the VisualElement. In other words, ref.current returns the Dom, and ref.current.ve returns the VisualElement.

We set the VisualElement's generateVisualContent property to the callback onGenerateVisualContent() where we use painter2D to draw an arc (a full circle in this case). onGenerateVisualContent() will be called everytime the VisualElement needs to repaint (during size changes, for example).

The Drawing logic is similar to the Canvas API (see CanvasRenderingContext2D) but limited in scope. Here we are basically drawing an 360 degrees arc in the dead center of the current VisualElement. We use ref.current.ve.resolvedStyle to get the actual rendered styling/dimension of the VisualElement.

Here Comes the Interop

So far, we have done everything in Typescript. In real world scenarios, we should have our core game logic in C#, and have our UI code listen for state changes. In other words, we should have our TS code subscribe to C# events.

  • Exit Unity Playmode
  • Create a new C# script in Unity, name it ProgressManager
  • Open the new script and replace the content with the following:
using System;
using System.Collections;
using UnityEngine;
using Random = UnityEngine.Random;

public class ProgressManager : MonoBehaviour {
    public float Progress => _progress;

    public event Action<float> OnProgressChanged;

    float _progress = 1f;

    void Start() {
        StartCoroutine(ChangeProgressCo());
    }

    IEnumerator ChangeProgressCo() {
        var waitTime = Random.Range(1f, 5f); // Wait for a random time
        yield return new WaitForSeconds(waitTime);
        ChangeProgress();
        StartCoroutine(ChangeProgressCo()); // Repeat
    }

    void ChangeProgress() {
        var p = Random.Range(0, 1f);
        while (Mathf.Abs(p - _progress) < 0.2f) { // Roll a number that's fairly different from the current value
            p = Random.Range(0, 1f);
        }
        _progress = p;
        OnProgressChanged?.Invoke(_progress);
    }
}

This basically updates a progress value (0 to 1f) every few seconds. An event is fired everytime the progress value changes.

Now let's set it up so that a ProgressManager object is accessible from Typescript.

  • In the Hierarchy panel, create a new GameObject, call it ProgressManager
  • Add the component ProgressManager to it.
  • While having the ProgressManager GameObject selected, right-click on the Inspector tab, and pick "Properties". (This opens up a standalone popup window for the ProgressManager GameObject)
  • Now, select the ScriptEngine GameObject and locate the Objects list under INTEROP.
  • Drag the ProgressManager component from the popup window onto the Objects list.
  • Name the mapping to "pman"

Now your ProgressManager MonoBehaviour object will be accessible from Typescript via require("pman"). This is just one of the many ways you can do interop between C# and Typescript. Refer to the Jint ReadMe for more info.

Let's use pman to drive our RadialProgress.

  • Go back to index.tsx in VSCode
  • Add a progress prop to the RadialProgress component
const RadialProgress = ({ progress }: { progress: number }) => {
    ...
  • Modify onGenerateVisualContent's painter2D.Arc() to include the progress
function onGenerateVisualContent(mgc: MeshGenerationContext) {
    ...
    painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(progress * 360), ArcDirection.Clockwise)
    ...
}
  • Add another useEffect() block after the first one:
...
useEffect(() => {
    ref.current.ve.generateVisualContent = onGenerateVisualContent
    ref.current.ve.MarkDirtyRepaint()
}, [progress])
...

We use this new useEffect() block to track changes to progress and trigger repaint everytime it changes. Next, we add an App comp to hold the RadialProgress comp and use the useEventfulState() hook (this is provided by OneJS, not Preact) to grab the C# state conveniently.

  • Replace the previous render() line with the following:
const App = () => {
    const pman = require("pman")
    const [progress, _] = useEventfulState(pman, "Progress")

    return <RadialProgress progress={progress} />
}

render(<App />, document.body)

NOTE: useEventfulState(obj, "Progress") assumes the C# obj has a property named "Progress" and an event named "OnProgressChanged". Our ProgressManager satisfies both conditions.

Here's our result:

Animation is sped up here

Let's add a number display too.

  • Modify RadialProgress as follows:
const RadialProgress = ({ progress }: { progress: number }) => {
    const ref = useRef<Dom>()

    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        const resolvedStyle = ref.current.ve.resolvedStyle
        const minSize = Mathf.Min(resolvedStyle.width, resolvedStyle.height)
        ref.current.style.fontSize = minSize * 0.3
    }, [])

    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        ref.current.ve.MarkDirtyRepaint()
    }, [progress])

    function onGenerateVisualContent(mgc: MeshGenerationContext) {
        ...
    }

    return <div ref={ref} class={emo`width:100%;height:100%;display:flex;justify-content:center;align-items:center;color:#305fbc;`}>{Math.round(progress * 100)}</div>
}
Animation is sped up here
Click for Full Code
import { emo } from "onejs/styled"
import { useEventfulState } from "onejs"
import { parseColor } from "onejs/utils/color-parser"
import { h, render } from "preact"
import { useRef, useEffect } from "preact/hooks"
import { Mathf, Vector2 } from "UnityEngine"
import { MeshGenerationContext, Angle, ArcDirection } from "UnityEngine/UIElements"

const RadialProgress = ({ progress }: { progress: number }) => {
    const ref = useRef<Dom>()

    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        const resolvedStyle = ref.current.ve.resolvedStyle
        const minSize = Mathf.Min(resolvedStyle.width, resolvedStyle.height)
        ref.current.style.fontSize = minSize * 0.3
    }, [])

    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        ref.current.ve.MarkDirtyRepaint()
    }, [progress])

    function onGenerateVisualContent(mgc: MeshGenerationContext) {
        var painter2D = mgc.painter2D

        const resolvedStyle = ref.current.ve.resolvedStyle
        let radius = Mathf.Min(resolvedStyle.width, resolvedStyle.height) / 2
        let dx = resolvedStyle.width / 2 - radius
        let dy = resolvedStyle.height / 2 - radius

        painter2D.strokeColor = parseColor("#305fbc")
        painter2D.lineWidth = radius * 0.2
        painter2D.BeginPath()
        painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(progress * 360), ArcDirection.Clockwise)
        painter2D.Stroke()
        painter2D.ClosePath()
    }

    return <div ref={ref} class={emo`width:100%;height:100%;display:flex;justify-content:center;align-items:center;color:#305fbc;`}>{Math.round(progress * 100)}</div>
}

const App = () => {
    const pman = require("pman")
    const [progress, _] = useEventfulState(pman, "Progress")

    return <RadialProgress progress={progress} />
}

render(<App />, document.body)

Smooth it out

For this last section, let's make the RadialProgress have smooth transitions when value changes. For that, we can use the tween.js library that comes with OneJS.

So we need to smoothly animate both the display text and the arc. In OneJS, every TextNode is its own VisualElement. To make the text easier to modify, we'll use a label element.

  • Add a new ref to RadialProgress:
const labelRef = useRef<Dom>()
  • Modify the return line of RadialProgress like this:
return <div ref={ref} class={emo`width:100%;height:100%;display:flex;justify-content:center;align-items:center;color:#305fbc;`}><label ref={labelRef} /></div>

We need an intermediate value that'll converge to the progress value. And since we are working with Stateless Function Components, we need a way for the intermediate value to persist between re-renders. We can use useRef() for this.

  • Add another useRef() after the labelRef we just added:
const prev = useRef(progress) // using this to persist data between renders
  • Update the onGenerateVisualContent callback function to replace progress with prev.current:
...
painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(prev.current * 360), ArcDirection.Clockwise)
...

Finally, we can use the tween.js api to smoothly tween the prev ref to the new progress value, updating the display text via labelRef along the way.

  • Change the 2nd useEffect() block like this:
useEffect(() => {
   ref.current.ve.generateVisualContent = onGenerateVisualContent
   ref.current.ve.MarkDirtyRepaint()

   new Tween(prev).to({ current: progress }, 300)
       .easing(Easing.Quadratic.InOut).onUpdate(() => {
               ref.current.ve.MarkDirtyRepaint();
               (labelRef.current.ve as TextElement).text = Math.round(prev.current * 100) + ""
       }).start()
}, [progress])

tween.js requires the use of requestAnimationFrame() (it's a standard web api which OneJS also provides).

  • Append the following to the end of the file:
function animate(time) {
   requestAnimationFrame(animate)
   update(time)
}
requestAnimationFrame(animate)

And voila! Here's the final result:

Animation is sped up here
Click for Full Code
import { Dom } from "OneJS/Dom"
import { emo } from "onejs/styled"
import { h, render } from "preact"
import { useEventfulState } from "onejs"
import { Mathf, Vector2 } from "UnityEngine"
import { Easing, Tween, update } from "tweenjs"
import { useRef, useEffect } from "preact/hooks"
import { parseColor } from "onejs/utils/color-parser"
import { MeshGenerationContext, Angle, ArcDirection, TextElement } from "UnityEngine/UIElements"

const RadialProgress = ({ progress }: { progress: number }) => {
    const ref = useRef<Dom>()
    const labelRef = useRef<Dom>()
    const prev = useRef(progress) // using this to persist data between renders

    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        const resolvedStyle = ref.current.ve.resolvedStyle
        const minSize = Mathf.Min(resolvedStyle.width, resolvedStyle.height)
        ref.current.style.fontSize = minSize * 0.3
    }, [])

    useEffect(() => {
        ref.current.ve.generateVisualContent = onGenerateVisualContent
        ref.current.ve.MarkDirtyRepaint()

        new Tween(prev).to({ current: progress }, 300)
            .easing(Easing.Quadratic.InOut).onUpdate(() => {
                ref.current.ve.MarkDirtyRepaint();
                (labelRef.current.ve as TextElement).text = Math.round(prev.current * 100) + ""
            }).start()
    }, [progress])

    function onGenerateVisualContent(mgc: MeshGenerationContext) {
        var painter2D = mgc.painter2D

        const resolvedStyle = ref.current.ve.resolvedStyle
        let radius = Mathf.Min(resolvedStyle.width, resolvedStyle.height) / 2
        let dx = resolvedStyle.width / 2 - radius
        let dy = resolvedStyle.height / 2 - radius

        painter2D.strokeColor = parseColor("#305fbc")
        painter2D.lineWidth = radius * 0.2
        painter2D.BeginPath()
        painter2D.Arc(new Vector2(radius + dx, radius + dy), radius * 0.80, new Angle(0), new Angle(prev.current * 360), ArcDirection.Clockwise)
        painter2D.Stroke()
        painter2D.ClosePath()
    }

    return <div ref={ref} class={emo`width:100%;height:100%;display:flex;justify-content:center;align-items:center;color:#305fbc;`}><label ref={labelRef} /></div>
}

const App = () => {
    const pman = require("pman")
    const [progress, _] = useEventfulState(pman, "Progress")

    return <RadialProgress progress={progress} />
}

render(<App />, document.body)

function animate(time) {
    requestAnimationFrame(animate)
    update(time)
}
requestAnimationFrame(animate)

We've reached the end of this tutorial. Kudos to you for following all the way to the end! Everything we showed you so far are really just the tip of the iceberg. But hopefully this tutorial served its purpose of getting you up to speed with the breadth of tech stacks in OneJS.

Unity, C#, Typescript, and (P)React are incredibly deep and flexible tools. If you ever get stuck on something or come across a bug, make sure to stop by our Discord to let us know!