Project Setup

Configure TypeScript, esbuild, and your project structure.

Default Structure

JSRunner uses the Scene Folder convention. On first Play Mode, it creates:

Assets/Scenes/
└── YourScene/                    # Next to YourScene.unity
    └── MainUI_abc123/            # {Name}_{InstanceId}
        ├── MainUI~/              # Working directory (~ = Unity ignores)
        │   ├── index.tsx         # Entry point
        │   ├── package.json      # Dependencies
        │   ├── tsconfig.json     # TypeScript config
        │   ├── esbuild.config.mjs # Build config
        │   └── types/
        │       └── global.d.ts   # TypeScript declarations
        ├── app.js.txt            # Built bundle
        └── app.js.map.txt        # Source map

The ~ suffix on the working directory tells Unity to ignore it. Configure scaffolding via the Default Files field in JSRunner's inspector.

package.json

{
    "name": "onejs-app",
    "type": "module",
    "scripts": {
        "build": "node esbuild.config.mjs",
        "watch": "node esbuild.config.mjs --watch"
    },
    "dependencies": {
        "react": "^19.0.0",
        "onejs-react": "^0.1.0"
    },
    "devDependencies": {
        "@types/react": "^19.0.0",
        "esbuild": "^0.24.0",
        "typescript": "^5.7.0"
    }
}

tsconfig.json

},
    "include": ["./**/*.ts", "./**/*.tsx"],
    "exclude": ["node_modules", "dist"]
}

esbuild.config.mjs

import * as esbuild from "esbuild"
import path from "path"
import { fileURLToPath } from "url"
import { importTransformPlugin, tailwindPlugin } from "onejs-unity/esbuild"

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const isWatch = process.argv.includes("--watch")

const config = {
    entryPoints: ["index.tsx"],
    bundle: true,
    outfile: "../app.js.txt",      // Output to parent (instance folder)
    format: "esm",
    target: "es2022",
    jsx: "automatic",
    sourcemap: true,
    alias: {
        // Force single React instance
        "react": path.resolve(__dirname, "node_modules/react"),
        "react/jsx-runtime": path.resolve(__dirname, "node_modules/react/jsx-runtime"),
    },
    packages: "bundle",
    plugins: [
        importTransformPlugin(),   // Transform Unity imports
        tailwindPlugin({ content: ["./**/*.{tsx,ts,jsx,js}"] }),
    ],
}

if (isWatch) {
    const ctx = await esbuild.context(config)
    await ctx.watch()
    console.log("Watching for changes...")
} else {
    await esbuild.build(config)
    console.log("Build complete!")
}

Development Workflow

  1. Start the watcher:
npm run watch
  1. Enter Play Mode in Unity
  1. Edit your code: changes rebuild automatically
  1. OneJS hot-reloads when it detects the output file changed

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

Use @/ prefix for cleaner imports:

// Instead of
import { Button } from "../../../components/Button"

// Use
import { Button } from "@/components/Button"

Requires the paths config in tsconfig.json shown above.

Multiple Entry Points

For different UIs (menu, HUD, settings):

// esbuild.config.js
const entries = ["menu", "hud", "settings"]

await Promise.all(entries.map(name =>
    esbuild.build({
        ...config,
        entryPoints: [`./${name}/index.tsx`],
        outfile: `./dist/${name}.js`,
    })
))

Environment Variables

Pass build-time values:

// esbuild.config.js
await esbuild.build({
    ...config,
    define: {
        "process.env.NODE_ENV": watch ? '"development"' : '"production"',
        "__DEV__": watch.toString(),
    },
})
// Use in code
if (__DEV__) {
    console.log("Debug mode")
}

Optimizing Builds

For production builds:

await esbuild.build({
    ...config,
    minify: true,
    treeShaking: true,
    drop: ["console", "debugger"],
})