C# Interop#

The CS global gives you access to any C# type.

Accessing Types#

Access C# types through their full namespace:

// Static methods and properties
CS.UnityEngine.Debug.Log("Hello from JS!")
const dt = CS.UnityEngine.Time.deltaTime

// Create instances
const vec = new CS.UnityEngine.Vector3(1, 2, 3)
console.log(vec.x, vec.y, vec.z)

Your own code works the same way:

// C#
namespace MyGame {
    public class GameManager {
        public static int Score { get; set; }
        public static void AddScore(int points) { Score += points; }
    }
}
CS.MyGame.GameManager.AddScore(100)
const score = CS.MyGame.GameManager.Score

ES6 Imports#

With esbuild's import transform plugin, you can use ES6 import syntax instead of CS.*:

import { GameObject, Rigidbody } from "UnityEngine"
import { GameManager } from "MyGame"

const go = new GameObject("Player")
go.AddComponent(Rigidbody)
GameManager.AddScore(100)

This is the preferred style for app code. The examples below use it where applicable.

Checking a Type Exists#

A CS. path is never null, even when no such type is loaded, so testing one tells you nothing. From OneJS 3.9.5, onejs.cs.typeExists takes a type or its full name and says whether it is there:

if (onejs.cs.typeExists("UnityEngine.InputSystem.Keyboard")) {
    // the project has the Input System package
}

The first check of a name crosses to C# once; the answer is cached after that.

Working with Objects#

Use new to create C# objects. Properties and fields are accessed with dot notation:

import { GameObject, Rigidbody, MeshRenderer } from "UnityEngine"

const go = new GameObject("MyObject")

const rb = go.AddComponent(Rigidbody)
rb.mass = 2.0
rb.useGravity = true

const pos = go.transform.position
console.log(pos.x, pos.y, pos.z)

This works for any C# type: structs, classes, and your own types all use dot notation.

Note: console.log(obj) on a C# object shows its proxy handle (e.g. [CSObject UnityEngine.GameObject#27]), not the field values. To see actual data, log the fields directly: console.log(obj.x, obj.y).

Destroyed and Unassigned Objects#

A destroyed Unity object, or an inspector field left empty, reads as null, the same as it compares equal to null in C#. So optional chaining and === null work on the value you get back:

const target = enemy.target            // destroyed last frame
target?.transform.position             // undefined, no error
if (target === null) pickNewTarget()

A proxy you are already holding does not change when its object is destroyed; read the object again to find out. A C# exception thrown into JS is an ordinary JS error: catching it keeps the console clean, and one nobody catches is logged with its C# stack attached.

Enums#

Access enum values directly:

import { Space, Vector3, KeyCode } from "UnityEngine"

transform.Translate(new Vector3(1, 0, 0), Space.World)

if (keyCode === KeyCode.Space) {
    console.log("Space pressed!")
}

Enum values are plain numbers in JS. An enum member like KeyCode.Space resolves to its underlying integer, and an enum field read from a C# object returns that same number, so you can compare them with === and use bitwise math for [Flags] enums:

const dir = someComponent.Direction      // number, e.g. 1
if (dir === Direction.Bar) { /* ... */ } // compares correctly

const mask = Flags.A | Flags.B           // bitwise combine

A type nested inside another is reached through its owner, the way C# writes it:

import { ScrollView } from "UnityEngine/UIElements"

scrollView.touchScrollBehavior = ScrollView.TouchScrollBehavior.Elastic

A path that names no type warns in the console when it is used as a value, so a misspelled enum shows up instead of silently reading as 0.

Generic Types#

Create bound generic types with function-call syntax:

import { List, Dictionary } from "System.Collections.Generic"
import { Int32, String } from "System"

const numbers = new (List(Int32))()
numbers.Add(1)
numbers.Add(2)

const scores = new (Dictionary(String, Int32))()
scores.set_Item("player1", 100)

Generic Methods#

Generic methods (e.g., Create<T>(), GetValue<T>()) are not supported by the interop layer. Only generic types like List<T> and Dictionary<TKey, TValue> work, using the function-call syntax shown above.

The workaround is to add a non-generic wrapper method in C#:

// Instead of calling Create<MyThing>() from JS,
// add a non-generic overload in C#:
public Task<MyThing> CreateMyThing(CancellationToken ct) => Create<MyThing>(ct);
// Then call the non-generic version from JS
const result = await factory.CreateMyThing(cancellationToken)

Arrays and Collections#

C# collections use .Length/.Count and indexers:

import { Renderer } from "UnityEngine"

const renderers = go.GetComponentsInChildren(Renderer)
for (let i = 0; i < renderers.Length; i++) {
    renderers[i].enabled = false
}

Use toArray from onejs-react to convert to JS arrays for .map(), .filter(), etc.:

import { toArray } from "onejs-react"

const items = toArray<{ Name: string }>(inventory.Items)
items.map(item => console.log(item.Name))

// Safe with null, returns []
const npcs = toArray(currentPlace?.NPCs)

Both List<T> and T[] work with toArray.

Async Methods#

C# async Task methods return Promises:

// C#
public class DataLoader {
    public static async Task<string> LoadDataAsync(string url) { ... }
}
import { DataLoader } from "MyGame"

const data = await DataLoader.LoadDataAsync("/api/data")

See Async C# Methods for error handling and React patterns.

Events and Delegates#

Delegate Fields#

For C# delegate fields (Action, Action<T>, etc.), assign a JS function directly. This replaces any existing handler:

// C#
public struct PlayerData {
    public string name;
    public int score;
}

public class NetworkManager {
    public static Action OnConnected;
    public static Action<PlayerData> OnPlayerJoined;
}
import { NetworkManager } from "MyGame"

// No parameters
NetworkManager.OnConnected = () => console.log("Connected!")

// Typed parameter, access fields directly
NetworkManager.OnPlayerJoined = (player) => {
    console.log(player.name, player.score)
}

// Clear with null
NetworkManager.OnConnected = null

C# Events#

For C# events, use add_/remove_ (equivalent to +=/-=). Events support multiple subscribers:

const handler = () => console.log("Players changed!")
GameManager.add_OnPlayersChanged(handler)
GameManager.remove_OnPlayersChanged(handler)  // must pass the same reference

React Cleanup#

Clean up subscriptions in useEffect to avoid leaks during hot reload:

// Delegate field: assign and clear
useEffect(() => {
    NetworkManager.OnPlayerJoined = (player) => {
        setPlayers(prev => [...prev, { name: player.name, score: player.score }])
    }
    return () => { NetworkManager.OnPlayerJoined = null }
}, [])

// C# event: add and remove
useEffect(() => {
    const handler = () => setCount(GameManager.PlayerCount)
    GameManager.add_OnPlayersChanged(handler)
    return () => GameManager.remove_OnPlayersChanged(handler)
}, [])

Effect cleanups run whenever the runner tears down, including hot reload and a scene change that destroys the JSRunner, because OneJS unmounts the React tree before it disposes the context. For cleanup that belongs to no component, such as static delegates assigned at module level, use onStop:

export function onStop() {
    NetworkManager.OnPlayerJoined = null
}

See Lifecycle Hooks for details on onStop.

Calling JS from C##

Everything above flows from JS into C#. For the reverse direction, C# invoking your JS functions, there are three options, in order of preference.

The delegate pattern from the previous section already works both ways. JS assigns the function, and C# invokes it like any other delegate, without knowing or caring that the implementation lives in JavaScript:

// C# fires the delegate like plain C# code
NetworkManager.OnPlayerJoined?.Invoke(data);

This is the best-practice pattern: your C# code needs no reference to the OneJS runtime, hot reload is handled for you (reassigning a delegate frees the old native callback automatically), and it behaves the same in the Editor, native builds, and WebGL.

Func delegates work too: a JS function assigned to a Func<int, bool> field returns its value to C# (numbers, strings, bools, vectors and colors, C# objects, plain objects as Dictionary<string, object>, arrays as object[]):

// C#
public static Func<int, bool> CanAfford;
// JS
Shop.CanAfford = (price: number) => price <= gold

The built-in onPlay/onStop lifecycle hooks use this machinery too: export them from your entry file and JSRunner invokes them at the right times.

GetJSFunction#

When C# should own the call, bind a JS function by name to a typed delegate:

// JS: expose functions on globalThis (dotted paths work too)
globalThis.showToast = (message: string) => setToast(message)
globalThis.getLoadout = () => JSON.stringify(currentLoadout)
using OneJS;

var runner = GetComponent<JSRunner>();
var showToast = runner.GetJSFunction<Action<string>>("showToast");
var getLoadout = runner.GetJSFunction<Func<string>>("getLoadout");

showToast("Level complete!");
string loadout = getLoadout();

Resolution is lazy (the JS global only has to exist by the first call) and the delegate re-resolves after hot reload, so it stays valid for the runner's lifetime: fetch it once in Start and call it whenever. Func delegates marshal the return value like Func fields do. Invoking throws a clear exception if the runner isn't running or the global is missing. Up to 4 parameters.

If you cache anything else JS-related in C# (raw callback handles, parsed state), subscribe to runner.Reloaded to refresh it. It fires whenever the context is rebuilt: after each hot reload, and when a disabled JSRunner is enabled again.

Eval#

For quick one-off calls, evaluate a code string in the running context:

if (runner.IsRunning) {
    runner.Bridge.Eval("showToast('Level complete!')");
}

Eval returns the result as a string and throws if the script throws. Fine for debugging and triggers; prefer GetJSFunction when calls carry data, since building code strings has escaping pitfalls and no type checking.

Callback Handles (Zero-Allocation)#

For per-frame hot paths, register a JS function to get an integer handle, hand it to C#, and invoke it through the InvokeCallbackNoAlloc overloads, which cover common primitive and vector signatures with zero GC allocation (this is what OneJS itself uses to drive the frame tick and lifecycle hooks):

import { WaveSystem } from "MyGame"

declare function __registerCallback(fn: Function): number

WaveSystem.RegisterOnWave(__registerCallback((wave: number) => {
    console.log(`Wave ${wave} started`)
}))
public class WaveSystem {
    static int _onWaveHandle = -1;
    public static void RegisterOnWave(int handle) => _onWaveHandle = handle;

    public static void StartWave(JSRunner runner, int wave) {
        if (_onWaveHandle >= 0) runner.Bridge.Context.InvokeCallbackNoAlloc(_onWaveHandle, wave);
    }
}

The allocating InvokeCallback(handle, args) variant marshals arbitrary arguments and returns the JS function's return value. Handles belong to one JS context and a reload invalidates them; registering at module level (as above) refreshes the handle on every reload, and a stale handle fails with a clear error instead of misfiring.

Extension Methods#

C# extension methods aren't discoverable via reflection on the target type. Use useExtensions to register them (like a C# using statement):

useExtensions(CS.UnityEngine.UIElements.PointerCaptureHelper)

const el = ref.current
el.CapturePointer(0)       // PointerCaptureHelper.CapturePointer(el, 0)
el.ReleasePointer(0)       // PointerCaptureHelper.ReleasePointer(el, 0)
useExtensions(CS.UnityEngine.ImageConversion)

const tex = new CS.UnityEngine.Texture2D(2, 2)
tex.LoadImage(bytes)  // ImageConversion.LoadImage(tex, bytes)

Call useExtensions once per static class at module level.

From OneJS 3.9.5 the C# helpers are also under onejs.cs: onejs.cs.extensions is useExtensions, onejs.cs.typeOf is $typeof and onejs.cs.release is releaseObject.

Common Patterns#

Async Alternatives to Coroutines#

JS has async/await and requestAnimationFrame, so you often don't need C# coroutines:

import { Time, Color } from "UnityEngine"

async function fadeOut(renderer, duration) {
    const start = Time.time
    while (true) {
        await new Promise(r => requestAnimationFrame(r))
        const t = Math.min((Time.time - start) / duration, 1)
        renderer.material.color = new Color(1, 1, 1, 1 - t)
        if (t >= 1) break
    }
}

Per-Frame Logic#

import { Input, KeyCode } from "UnityEngine"

function update() {
    if (Input.GetKeyDown(KeyCode.Space)) {
        console.log("Jump!")
    }
    requestAnimationFrame(update)
}
requestAnimationFrame(update)

Performance Tips#

  1. Cache type references: avoid repeated lookups in hot loops
  2. Batch property access: read multiple values at once when possible
  3. Use fast paths: common operations like Time.deltaTime are optimized

For performance-critical code running every frame, see the Zero-Allocation Interop guide.