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   # Your own global declarations
        │   ├── AGENTS.md         # Guide for AI coding agents
        │   ├── .gitignore        # Leaves out node_modules/
        │   └── .onejs/
        │       └── scaffold      # Which default files this app was given
        ├── 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.). The Scaffolding list in JSRunner's inspector decides which files a new app gets.

Each default file is written once. ~/.onejs/scaffold records every one the app has been given, so a file you delete stays deleted, and entering Play mode never brings it back. To get one back on purpose, click Restore on its row. If a build fails because a file it needs is gone, the Console names the missing files and points you to Restore. A file OneJS adds to its templates in a later version is written into an existing app once, with a line in the Console naming it. (Before OneJS 3.5.1, a listed file that went missing came back on every Play.)

Commit ~/.onejs/ with the rest of the working directory. It holds small per-app OneJS state that the whole team shares, never caches or machine-local files, and the scaffolded .gitignore leaves it in.

Commit the working directory, not only the bundle. A clone that has app.js.txt but no ~/ is a new app to OneJS. The first Play writes the template files into ~/, and the watcher rebuilds app.js.txt from the template index.tsx, replacing your app with the starter one. The scaffolded .gitignore already leaves out node_modules/, so the rest of ~/ is safe to commit.

Development Workflow#

JSRunner runs npm run watch for you whenever the scene is open (edit-mode preview) or playing, so saving a file is all you do:

  1. Edit your code, esbuild rebuilds automatically
  2. OneJS hot-reloads when it detects the output file changed (works in both edit-mode preview and Play mode)

Run npm run watch yourself only when working without the Unity editor open.

package.json#

{
    "name": "onejs-app",
    "version": "1.0.0",
    "private": true,
    "type": "module",
    "scripts": {
        "build": "onejs-unity build",
        "watch": "onejs-unity watch",
        "typecheck": "tsc --noEmit"
    },
    "dependencies": {
        "react": "^19.0.0",
        "onejs-react": "^0.4.0",
        "onejs-ui": "^0.0.6",
        "onejs-unity": "^0.10.0",
        "ojplay": "^0.10.0"
    },
    "devDependencies": {
        "@types/react": "^19.0.0",
        "@types/react-reconciler": "~0.31.0",
        "esbuild": "^0.28.1",
        "typescript": "^7.0.0",
        "unity-types": "^6000.3.0"
    }
}

The versions shown are the ones OneJS 3.9.8 scaffolds. onejs-unity build builds the app from esbuild.config.mjs, and onejs-unity watch rebuilds it on every save.

  • onejs-react: React 19 reconciler for OneJS
  • onejs-ui: the themeable component library
  • onejs-unity: the build (oneJSConfig and its esbuild plugins: C# imports, Tailwind, CSS Modules, themes, shader programs), the types for the runtime's globals, and the runtime modules such as onejs-unity/assets and onejs-unity/fx
  • ojplay: the oj package (called onejs-play in projects OneJS made before the rename)
  • 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,
        "checkJs": false,
        "isolatedModules": true,
        "verbatimModuleSyntax": true,
        "esModuleInterop": true,
        "resolveJsonModule": true,
        "forceConsistentCasingInFileNames": true,
        "paths": {
            "onejs-react": ["./node_modules/onejs-react/src"],
            "oj": ["./node_modules/ojplay/src"]
        },
        "types": ["unity-types", "react", "onejs-unity/globals"]
    },
    "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. lib stays at ES2022 with no dom: there is no DOM in OneJS. The runtime globals that do exist (console, timers, fetch, URL, localStorage, __root, and the onejs namespace) are declared by onejs-unity/globals, which updates with the package. Your own global declarations go in types/global.d.ts; keep that file free of top-level import or export, otherwise it becomes a module and its declarations stop being global.

A project made before OneJS 3.9.5 has its own copy of the runtime's declarations in types/global.d.ts. To switch, add "onejs-unity/globals" to types and delete the copy, keeping any declarations of your own. Note there's no baseUrl: the paths entries are relative to the tsconfig itself, which keeps the config valid on TypeScript 5 through 7 (baseUrl is deprecated in TS 6 and removed in TS 7). New projects scaffold with TypeScript 7; if your IDE doesn't support TS 7's language server yet, pin typescript@6. The same config works on both.

esbuild.config.mjs#

import { oneJSConfig } from "onejs-unity/esbuild"

export default oneJSConfig({
    entry: "index.tsx",
    // With `import "onejs:tailwind"`, controls start bare, as on the web, so
    // utilities style them from nothing. Remove this to keep the theme's look.
    tailwind: { preflight: true },
    plugins: [
        // Your own esbuild plugins go here; they run after OneJS's.
    ],
})

oneJSConfig returns the esbuild options a OneJS app needs, so a fix to how apps build reaches your project through an npm update:

  • the bundle written to ../app.js.txt, where JSRunner reads it, with a source map beside it as app.js.map.txt
  • format: "iife" and globalName: "__exports": QuickJS runs the bundle as a script, and JSRunner finds onPlay and onStop on __exports
  • React and oj resolved to this app's own copies, since two copies of React break hooks
  • plain .uss files imported as text, for compileStyleSheet(), which is what makes global styles work in a player build
  • process.env.NODE_ENV set to "development", or "production" when the build runs with NODE_ENV=production, which is how a player build runs it (see Building)

And five plugins:

  • importTransformPlugin: transforms import { X } from "UnityEngine" into const { X } = CS.UnityEngine
  • tailwindPlugin: enables Tailwind utility classes via import "onejs:tailwind"
  • themesPlugin: registers theme packs via import "onejs:themes"
  • ussModulesPlugin: enables CSS Modules (.module.uss files) with auto-generated .d.ts type files
  • slPlugin: compiles .sl shader programs at build time (see The Shader Language)

Any other esbuild option goes in the same call. alias, define and loader are added to OneJS's rather than replacing them, and tailwind passes options to the Tailwind plugin:

export default oneJSConfig({
    entry: "index.tsx",
    alias: { "lodash": "lodash-es" },
    tailwind: { safelist: ["bg-red-500"] },
})

A project made before OneJS 3.9.5 has the whole esbuild setup in this file instead, run by node esbuild.config.mjs. It keeps working; to switch, replace the file with the one above and set the build and watch scripts as shown under package.json.

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

The oj package#

oj is a smaller, C#-free surface for app and game code: the components and hooks you already have, filtered so nothing in them needs a C# object, plus a stage, a frame loop and JavaScript versions of Vector2, Color and Mathf. Source written against it runs unchanged both here and on play.onejs.com.

OneJS 3.2.3 and later scaffold it for you. Projects created before that need ojplay installed plus an oj entry in both the esbuild alias block and the tsconfig.json paths, since esbuild reads one and TypeScript reads the other. The overview has the exact entries.

Adding the dependency costs nothing until something imports it. The starter entry file deliberately still uses render.

Path Aliases#

For cleaner imports in larger projects, add a @/* path alias to your tsconfig.json:

"paths": {
    "onejs-react": ["./node_modules/onejs-react/src"],
    "oj": ["./node_modules/ojplay/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.

Not on WebGL. There every JSRunner shares the page's one JavaScript realm, so a second one takes over the first's events, timers and teardown, and the first stops responding; the browser console warns when it happens. A web build keeps to one JSRunner, with the HUD and the menu as components inside it.

Editor Update Modes (the OneJS Overlay)#

The Scene view carries a OneJS overlay (turn it on from the Scene view's Overlays menu if hidden) with an editor FPS readout and two dropdowns that decide which JSRunners tick in the editor. This is what keeps a scene with several panels cheap: suspended runners keep their rendered UI but their JavaScript clock stops, so setTimeout, requestAnimationFrame, and pending async work wait until they tick again.

Scene applies while anything other than the Game view is focused (edit-mode preview):

  • Auto (default): if a JSRunner's GameObject is selected, only selected runners tick; otherwise the runner closest to the Scene view camera, among the visible ones, ticks
  • Camera: runners visible to the Scene view camera tick
  • Selected: only selected runners tick
  • None: no edit-mode ticking

Game applies while the Game view is focused, and always in Play mode:

  • Camera (default): runners visible to the game camera tick. Screen-space panels always count as visible, so a normal UI panel never suspends here; only world-space panels get culled
  • All: every runner ticks

Two details worth knowing. In edit-mode preview, file watching keeps running for suspended runners, so hot reload works even while a preview is not ticking; in Play mode, a suspended runner pauses hot reload too. And the filter is editor-only: player builds always tick every runner.

If a preview looks frozen (animations stopped, timers silent) while hot reload still works, this is almost always why: in Auto mode, select the runner's GameObject or switch Scene to Camera. The dropdown choices persist per machine, across projects.

Persistent UI Across Scenes#

For one UI shared across many scenes:

  1. Toggle Don't Destroy On Load in JSRunner's inspector
  2. Place JSRunner in your first scene (or a bootstrap scene)

The GameObject survives scene loads. One bundle, one React tree, no per-scene redundancy.

Relocating the Project Folder#

The scene folder is just the default scaffolding location. The PanelSettings asset is the actual project marker.

Move the whole project folder (PanelSettings + ~/ + app.js.txt) anywhere under Assets/. JSRunner follows the PanelSettings asset.

Useful for shared UI at Assets/Shared/MainUI/ referenced by JSRunners in multiple scenes.

Environment Variables#

Pass build-time values with define. process.env.NODE_ENV is already defined, "production" in a player build and "development" otherwise:

// esbuild.config.mjs
export default oneJSConfig({
    entry: "index.tsx",
    define: {
        "__DEV__": JSON.stringify(process.env.NODE_ENV !== "production"),
    },
})

Declare it for TypeScript in types/global.d.ts:

declare const __DEV__: boolean
// 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, before teardown during a live reload, and when a disabled JSRunner is enabled again
  • __isPlaying is a global boolean (onejs.isPlaying from OneJS 3.9.5): true in play mode, false in edit-mode preview
  • The format: "iife" and globalName: "__exports" that oneJSConfig sets are what make lifecycle hooks work, so do not override them

Optimizing Builds#

A player build already gets React's production build. To minify it as well, or drop debugger statements, add the options for production only:

const production = process.env.NODE_ENV === "production"

export default oneJSConfig({
    entry: "index.tsx",
    minify: production,
    drop: production ? ["debugger"] : [],
})