Project Setup#
Configure TypeScript, esbuild, and your project structure.
Default Structure#
JSRunner uses the Scene Folder convention. When you click Initialize Project in JSRunner's inspector, it creates:
Assets/Scenes/
└── YourScene/ # Next to YourScene.unity
└── MainUI/ # Named after the GameObject
├── ~/ # Working directory (~ = Unity ignores)
│ ├── index.tsx # Entry point
│ ├── package.json # Dependencies
│ ├── tsconfig.json # TypeScript config
│ ├── esbuild.config.mjs # Build config
│ ├── styles/
│ │ └── main.uss # Default stylesheet
│ ├── types/
│ │ └── global.d.ts # TypeScript declarations
│ └── AGENTS.md # Guide for AI coding agents
├── PanelSettings.asset # UI panel configuration
├── UIDocument.uxml # Visual tree asset
├── app.js.txt # Built bundle
└── app.js.map.txt # Source mapThe ~ suffix tells Unity to ignore the working directory. If a folder with the same name already exists, a counter is appended (MainUI_1, MainUI_2, etc.). Configure scaffolding via the Default Files field in JSRunner's inspector.
Development Workflow#
- Start the watcher:
npm run watch- Edit your code, esbuild rebuilds automatically
- OneJS hot-reloads when it detects the output file changed (works in both edit-mode preview and Play mode)
package.json#
{
"name": "onejs-app",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "node esbuild.config.mjs",
"watch": "node esbuild.config.mjs --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19.0.0",
"onejs-react": "^0.1.0",
"onejs-unity": "^0.2.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"esbuild": "^0.24.0",
"typescript": "^7.0.0",
"unity-types": "^6000.3.0"
}
}- onejs-react: React 19 reconciler for OneJS
- onejs-unity: esbuild plugins for C# imports, Tailwind, and CSS Modules
- unity-types: TypeScript declarations for Unity C# namespaces
tsconfig.json#
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowJs": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"onejs-react": ["./node_modules/onejs-react/src"]
},
"types": ["unity-types", "react"]
},
"include": ["**/*", "types/**/*.d.ts"],
"exclude": ["node_modules", "@outputs"]
}The onejs-react path mapping points to source files for editor click-through. The unity-types package provides type declarations for C# namespaces like UnityEngine. Note there's no baseUrl: the paths entries are relative to the tsconfig itself, which keeps the config valid on TypeScript 5 through 7 (baseUrl is deprecated in TS 6 and removed in TS 7). New projects scaffold with TypeScript 7; if your IDE doesn't support TS 7's language server yet, pin typescript@6. The same config works on both.
esbuild.config.mjs#
import * as esbuild from "esbuild"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { importTransformPlugin, tailwindPlugin, ussModulesPlugin } from "onejs-unity/esbuild"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const isWatch = process.argv.includes("--watch")
// Resolve react to this app's node_modules to prevent duplicate copies
const reactPath = path.resolve(__dirname, "node_modules/react")
const reactJsxPath = path.resolve(__dirname, "node_modules/react/jsx-runtime")
const reactJsxDevPath = path.resolve(__dirname, "node_modules/react/jsx-dev-runtime")
const config = {
entryPoints: ["index.tsx"],
bundle: true,
outfile: "../app.js.txt",
format: "iife", // IIFE required for onPlay/onStop lifecycle hooks
globalName: "__exports", // Exported functions available as __exports.onPlay, etc.
target: "es2022",
jsx: "automatic",
resolveExtensions: [".tsx", ".ts", ".jsx", ".js", ".json"],
sourcemap: true,
alias: {
"react": reactPath,
"react/jsx-runtime": reactJsxPath,
"react/jsx-dev-runtime": reactJsxDevPath,
},
packages: "bundle",
plugins: [
importTransformPlugin(), // Transform C# imports
tailwindPlugin({ content: ["./**/*.{tsx,ts,jsx,js}"] }), // Tailwind utilities
ussModulesPlugin({ generateTypes: true }), // CSS Modules with .d.ts
],
}
// Rename sourcemap to .map.txt for Unity TextAsset compatibility
function renameSourceMap() {
const oldPath = path.resolve(__dirname, "../app.js.txt.map")
const newPath = path.resolve(__dirname, "../app.js.map.txt")
if (fs.existsSync(oldPath)) fs.renameSync(oldPath, newPath)
}
if (isWatch) {
const ctx = await esbuild.context({
...config,
plugins: [
...(config.plugins || []),
{ name: "rename-sourcemap", setup(build) { build.onEnd(() => renameSourceMap()) } }
]
})
await ctx.watch()
console.log("Watching for changes...")
} else {
await esbuild.build(config)
renameSourceMap()
console.log("Build complete!")
}Three plugins are included by default:
- importTransformPlugin: transforms
import { X } from "UnityEngine"intoconst { X } = CS.UnityEngine - tailwindPlugin: enables Tailwind utility classes via
import "onejs:tailwind" - ussModulesPlugin: enables CSS Modules (
.module.ussfiles) with auto-generated.d.tstype files
Adding Components#
Organize your code into components:
YourApp/
├── index.tsx
├── components/
│ ├── Button.tsx
│ ├── Card.tsx
│ └── Header.tsx
├── hooks/
│ └── useGameState.ts
├── styles/
│ └── main.uss
└── utils/
└── helpers.tsThe oj package#
oj is a smaller, C#-free surface for app and game code: the components and hooks you already have, filtered so nothing in them needs a C# object, plus a stage, a frame loop and JavaScript versions of Vector2, Color and Mathf. Source written against it runs unchanged both here and on play.onejs.com.
OneJS 3.2.3 and later scaffold it for you. Projects created before that need onejs-play installed plus an oj entry in both the esbuild alias block and the tsconfig.json paths, since esbuild reads one and TypeScript reads the other. The overview has the exact entries.
Adding the dependency costs nothing until something imports it. The starter entry file deliberately still uses render.
Path Aliases#
For cleaner imports in larger projects, add a @/* path alias to your tsconfig.json:
"paths": {
"onejs-react": ["./node_modules/onejs-react/src"],
"@/*": ["./*"]
}Then use the @/ prefix:
// Instead of
import { Button } from "../../../components/Button"
// Use
import { Button } from "@/components/Button"Multiple UIs#
If your app has distinct screens (menu, HUD, settings), there are two approaches.
Single app with React state#
Handle all screens within one entry point using conditional rendering:
function App() {
const [screen, setScreen] = useState("menu")
if (screen === "menu") return <Menu onStart={() => setScreen("hud")} />
if (screen === "hud") return <HUD onPause={() => setScreen("settings")} />
if (screen === "settings") return <Settings onBack={() => setScreen("hud")} />
}One JSRunner, one bundle, shared state across screens. This is the simplest approach and works well for most apps.
Separate JSRunner instances#
For truly independent UI layers (e.g., a HUD that persists while a menu overlays it), use multiple JSRunners on separate GameObjects, each with its own PanelSettings and working directory:
Assets/Scenes/YourScene/
├── HUD/~/ # JSRunner 1
│ ├── index.tsx
│ └── esbuild.config.mjs
└── Menu/~/ # JSRunner 2
├── index.tsx
└── esbuild.config.mjsEach JSRunner builds and runs independently with its own JS context. Toggle them by enabling/disabling their GameObjects.
Editor Update Modes (the OneJS Overlay)#
The Scene view carries a OneJS overlay (turn it on from the Scene view's Overlays menu if hidden) with an editor FPS readout and two dropdowns that decide which JSRunners tick in the editor. This is what keeps a scene with several panels cheap: suspended runners keep their rendered UI but their JavaScript clock stops, so setTimeout, requestAnimationFrame, and pending async work wait until they tick again.
Scene applies while anything other than the Game view is focused (edit-mode preview):
- Auto (default): if a JSRunner's GameObject is selected, only selected runners tick; otherwise the runner closest to the Scene view camera, among the visible ones, ticks
- Camera: runners visible to the Scene view camera tick
- Selected: only selected runners tick
- None: no edit-mode ticking
Game applies while the Game view is focused, and always in Play mode:
- Camera (default): runners visible to the game camera tick. Screen-space panels always count as visible, so a normal UI panel never suspends here; only world-space panels get culled
- All: every runner ticks
Two details worth knowing. In edit-mode preview, file watching keeps running for suspended runners, so hot reload works even while a preview is not ticking; in Play mode, a suspended runner pauses hot reload too. And the filter is editor-only: player builds always tick every runner.
If a preview looks frozen (animations stopped, timers silent) while hot reload still works, this is almost always why: in Auto mode, select the runner's GameObject or switch Scene to Camera. The dropdown choices persist per machine, across projects.
Persistent UI Across Scenes#
For one UI shared across many scenes:
- Toggle Don't Destroy On Load in JSRunner's inspector
- Place JSRunner in your first scene (or a bootstrap scene)
The GameObject survives scene loads. One bundle, one React tree, no per-scene redundancy.
Relocating the Project Folder#
The scene folder is just the default scaffolding location. The PanelSettings asset is the actual project marker.
Move the whole project folder (PanelSettings + ~/ + app.js.txt) anywhere under Assets/. JSRunner follows the PanelSettings asset.
Useful for shared UI at Assets/Shared/MainUI/ referenced by JSRunners in multiple scenes.
Environment Variables#
Pass build-time values:
// esbuild.config.mjs
await esbuild.build({
...config,
define: {
"process.env.NODE_ENV": isWatch ? '"development"' : '"production"',
"__DEV__": isWatch.toString(),
},
})// Use in code
if (__DEV__) {
console.log("Debug mode")
}Lifecycle Hooks#
OneJS supports onPlay() and onStop() lifecycle hooks. Export them from your entry file to separate game logic from UI setup:
import { render, View, Label } from "onejs-react"
function App() {
return (
<View style={{ padding: 20 }}>
<Label>{__isPlaying ? "Playing" : "Edit Mode Preview"}</Label>
</View>
)
}
// Module-level: runs in BOTH edit-mode preview and play mode
render(<App />, __root)
// Only called when entering Play mode
export function onPlay() {
// Spawn GameObjects, start physics, connect to game systems
}
// Only called when exiting Play mode (or before live reload during play)
export function onStop() {
// Cleanup, save state, disconnect
}Key points:
- Module-level code (including
render()) runs in both edit-mode preview and play mode, so keep it safe for UI preview onPlay()fires when entering Play mode, or after a live reload during playonStop()fires when exiting Play mode, or before teardown during a live reload__isPlayingis a global boolean:truein play mode,falsein edit-mode preview- The
format: "iife"andglobalName: "__exports"in esbuild config are required for lifecycle hooks to work
Optimizing Builds#
For production builds:
await esbuild.build({
...config,
minify: true,
treeShaking: true,
drop: ["console", "debugger"],
})