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 map

The ~ 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#

  1. Start the watcher:
npm run watch
  1. Edit your code, esbuild rebuilds automatically
  1. 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" into const { X } = CS.UnityEngine
  • tailwindPlugin: enables Tailwind utility classes via import "onejs:tailwind"
  • ussModulesPlugin: enables CSS Modules (.module.uss files) with auto-generated .d.ts type 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.ts

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.mjs

Each 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 play
  • onStop() fires when exiting Play mode, or before teardown during a live reload
  • __isPlaying is a global boolean: true in play mode, false in edit-mode preview
  • The format: "iife" and globalName: "__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"],
})