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.ScoreES6 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.
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 ?#27]), not the field values. To see actual data, log the fields directly:console.log(obj.x, obj.y).
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 combineGeneric 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(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 = nullC# 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 referenceReact 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)
}, [])useEffect cleanup works for hot reload, but won't run during scene transitions because Unity destroys the JSRunner before React can run cleanup functions. If your app uses multiple scenes, null out static delegates in onStop instead:
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.
Delegates (Recommended)#
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 <= goldThe 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 after each hot reload.
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.
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#
- Cache type references: avoid repeated lookups in hot loops
- Batch property access: read multiple values at once when possible
- Use fast paths: common operations like
Time.deltaTimeare optimized
For performance-critical code running every frame, see the Zero-Allocation Interop guide.