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
├── 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": "^5.7.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,
"baseUrl": ".",
"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.
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.tsPath 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.
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"],
})