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: "iife", // IIFE enables onPlay/onStop lifecycle hooks
globalName: "__exports", // Exports available as __exports.onPlay, etc.
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")
}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 — 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"],
})