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 mapThe ~ 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
- Start the watcher:
npm run watch- Enter Play Mode in Unity
- Edit your code: changes rebuild automatically
- 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.tsPath 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"],
})