This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
ReactUnity: a React renderer that draws UI inside Unity3D (UGUI, UIToolkit, and the Unity Editor) without a DOM. This is a monorepo formed by merging ~10 previously separate ReactUnity repos (renderer, scripts, material, create, core, jint, quickjs, clearscript, docs, tests, full-sample (now kitchen-sink/), samples) with their histories preserved — see the chore: merge X into Y commits. Much of the layout only makes sense in that light, and the workflow files under .github/workflows/ carry detailed comments explaining why each piece is shaped the way it is. Read those before changing CI.
npm (packages/*) |
UPM / Unity (unity/*) |
|
|---|---|---|
| Members | @reactunity/renderer, scripts, material, create |
com.reactunity.core, jint, quickjs, clearscript |
| Consumed by | the user's React app | the user's Unity project |
| Published by | .github/workflows/release-npm.yml (Tegami + npm OIDC) |
.github/workflows/release-upm.yml (manual dispatch, orphan branch per package) |
All eight share one version number (currently 0.22.0). Tegami bumps packages/*; the syncUnityVersions plugin in scripts/tegami.mts copies that version into the four unity/*/package.json manifests.
unity/*/package.json files are UPM manifests, not npm manifests. Their dependencies are Unity package names (com.unity.editorcoroutines). They are deliberately excluded from the pnpm workspace — adding them would send pnpm to the npm registry looking for Unity packages.
Defined in pnpm-workspace.yaml, which documents every inclusion and exclusion. Members are: packages/*, unity/core/.react/* (three React apps embedded in the core Unity package; the .react dot-prefix hides them from Unity's asset importer), kitchen-sink/react, and docs.
Explicit non-members: samples/** (rotted — React 19 alongside React-18-capped redux deps), packages/create/scaffold/** (a template whose deps are placeholders), and anything under Library/ or PackageCache/ (Unity's own package cache is full of package.json files).
overrides in the same file force every @reactunity/* request to resolve to the workspace copy — without it pnpm downloads registry copies of packages that exist right here, and hard-fails once the local version is one npm has never seen.
Node >= 26 (.node-version pins 26), pnpm 11.17.0 via packageManager.
pnpm installpnpm buildBuilds packages/* only, topologically (material needs renderer's dist). tsdown is the bundler — one tsdown.config.ts per package, and tsc never emits. Output is bundled per entry rather than mirroring src, and targets ES2015, which is rolldown's floor (it cannot emit ES5). The config comments cover the externals.
pnpm typechecktsc --noEmit across the workspace, and the only thing that type checks — neither tsdown nor webpack does. CI runs it as its own step.
pnpm checkBiome lint + format check over the whole repo — this is what CI runs. pnpm lint and pnpm format are the narrower variants. Husky + lint-staged run biome check --write on commit.
Per-package work uses pnpm filters:
pnpm --filter @reactunity/renderer buildpnpm --filter @reactunity/material watchEach app (kitchen-sink/react, unity/core/.react/*) uses react-unity-scripts, a CRA fork:
pnpm --filter reactunity-kitchen-sink startstart runs a dev server with HMR that Unity connects to (and serves a browser previewer at the port). build emits to BUILD_PATH — by default ../Assets/Resources/react, overridden per app in its .env. react-unity-scripts start --test swaps the entry point to test.ts. See packages/scripts/README.md for the full env-var surface (FILENAME, BUILD_PATH, JSX_IMPORT_SOURCE, …).
docs/ is an Astro site (it was a Next 12 fork of react.dev until the migration; the shape and MDX component set are still React's). pnpm --filter react-website start serves it on port 4321, pnpm --filter react-website build writes docs/dist. There is no type-check command for it -- see below. docs/README.md explains how a page is assembled; the parts worth knowing before editing it:
- Pages are
.mdxfiles indocs/src/content, wired up bydocs/src/content.config.tsand rendered bydocs/src/pages/[...slug].astro, which also owns the element-name → component map. Navigation, titles and prev/next links come fromsidebarLearn.json/sidebarReference.json, not frontmatter.layout:is not usable in frontmatter — Astro treats it as a component to import. - Almost everything renders to static HTML. Only three things ship JS: the nav (
client:load), the table of contents (client:load) and each<Sandpack>(client:visible). Components in the MDX map are.astrofiles on purpose — a React component there receives its children as an opaque<astro-slot>blob, so anything that needs to read its children (<Sandpack>'s code fences) gets the data from a remark plugin instead. - One Unity WebGL player is shared by every example on a page, via a module-level singleton in
docs/src/components/unity/global.tsx. It cannot be React context: each Sandpack is a separate island with its own React root, and the player is a ~100 MB download.
pnpm unity compile testspnpm unity test testsscripts/unity/ drives a local Editor headlessly — compile (~8 s warm, the cheapest check on any C# edit), test, open, editors, and bridge for talking to an Editor that is already open. pnpm unity help lists it all, and .claude/skills/unity covers which path to use and what bites. Two things worth knowing before running it:
- The editor is pinned per project —
tests/on 6000.1.4f1,kitchen-sinkon 6000.5.5f1.tests/cannot use 6000.5: its committed manifest resolvescom.unity.inputsystem/test-framework.performanceversions usingTreeView, which 6000.5 made obsolete-as-error, giving 306 compile errors before any test runs.UNITY_VERSION=overrides. CI runs 6000.0.51f1/6000.1.9f1, so a local pass is still not a matrix pass. - Opening
tests/rewrites its manifest into a 6000-only shape —com.unity.ugui2.x, notextmeshpro, plusmodules.physicscore2d/vectorgraphics/adaptiveperformance— and that manifest fails to resolve on 6000.1 (measured), which yields zero tests rather than a red suite. The CLI snapshots those files and restores them after every run;--no-restoreopts out. Restore covers batch runs only — an interactive Editor churns them freely, so checkgit statusafter one.
The Test Runner window still works, as does .github/workflows/unity-tests.yml for the real matrix. tests/Packages/manifest.json already points at file:../../unity/*, so the four Unity packages are wired up with no patching.
The Editor bridge itself is unity/core/Editor/Developer/AgentBridge: a loopback TCP server in its own asmdef, gated on REACT_UNITY_DEVELOPER and never started in batch mode. It is a separate assembly because it references UnityEditor.TestRunner, which ReactUnity.Editor must not depend on.
Rendering tests compare against snapshots in unity/core/Tests/.snapshots/{linux,windows}. To regenerate: the React > Tests > Overwrite Snapshots editor menu toggle (needs the REACT_UNITY_DEVELOPER define), the -reactOverwriteSnapshots command-line arg, [snapshots] in a commit message, or the workflow's overwrite-snapshots dispatch input. CI commits regenerated snapshots from the one matrix job marked main: true.
pnpm tegamiTegami config lives in scripts/tegami.mts (unrelated to packages/scripts, despite the name). Changelog entries are pending .tegami/*.md files; tegami ci on main either opens a "Version Packages" PR or publishes from the committed publish lock. UPM releases are separate and manual (release-upm.yml, workflow_dispatch).
kitchen-sink/ is both the project ReactUnity is manually tested against and the sample users are pointed at, so it is published standalone on the kitchen-sink orphan branch by release-kitchen-sink.yml.
node scripts/kitchen-sink/prepare.mts Logs/kitchen-sink --forceprepare.mts is the whole transform, and the workflow only packages what it produces — so run it locally to see exactly what users get. It copies the tracked files (git ls-files, which keeps the exclusion list honest), rewrites file:../../unity/* and workspace:* to the current published version, drops com.reactunity.jint/clearscript and the testables block, strips REACT_UNITY_DEVELOPER, and moves any scene pinned to a dropped engine back to EngineType: Auto.
Then it verifies, which is the part that matters: it fails if OpenUPM or npm have not published the pinned version yet. That is why this workflow is not chained to release-npm.yml the way release-upm.yml is — both registries build asynchronously after a release, and a manifest pinning a version they do not have gives a user an empty Packages folder, the same silent failure the file: refs cause. Dispatch it once they have caught up.
Anything added to kitchen-sink/ that only works inside this checkout has to be handled in prepare.mts, or the exported project breaks in a way nothing here would catch.
@reactunity/renderer is a react-reconciler host config that never touches a DOM. Two reconcilers live side by side in packages/renderer/src/renderer/:
sync/— calls into C# directly, one interop call per operation.async/— the default. Serializes mutations into a command buffer (async/commands.ts,async/serializer.ts), flushed once per microtask. Objects crossing the boundary are handles tracked inasync/objects.ts. Batching is what makes inline rich-text and SVG subcontexts possible; disabling it (disableBatchRendering) trades those away for lower per-call overhead on Jint.
The C# side of that call surface is unity/core/Runtime/Core/ReactUnityBridge.cs — createElement, appendChild, applyUpdate, and friends. Everything reachable from JS is [Preserve]d against IL2CPP stripping.
packages/renderer/src/models/generated/*.ts is emitted by unity/core/Editor/Developer/TypescriptModelsGenerator.cs, which reflects over the Unity assemblies. Biome ignores models/generated. Do not hand-edit those files — change the C# type (or the generator's include/remap options) and regenerate from the Unity Editor.
unity/core/Runtime/Frameworks/ holds three backends behind the same component interfaces: UGUI (the mature one, with its own measurers, shapes, and state handlers), UIToolkit, and Noop (headless, used by tests). Each has its own asmdef. The matching TS type surfaces are @reactunity/renderer/ugui, /uitoolkit, /editor — each with its own jsx-runtime, so a project picks its element namespace by which one it imports.
unity/core/Runtime/Scripting/ defines IJavaScriptEngine plus DOM shims (DomProxies/ — fetch, XMLHttpRequest, WebSocket, localStorage, URL). Concrete engines ship as separate UPM packages so a project pulls in only one native binary: com.reactunity.quickjs (recommended), jint (pure C#, slower), clearscript (V8).
unity/core/Runtime/Styling/ implements a CSS subset over Yoga flexbox. Note for anything UI-facing: flex direction defaults to column, not row; CSS cannot style SVG icons from libraries like react-icons (use their color/size props); emoji are not reliably supported.
These are load-bearing and easy to undo (see commit 43e90688):
packages/scripts/tsconfig.jsonmust keeppreserveSymlinks: false. Every consumer extends this config; under pnpm every dependency is a symlink, andtruebreaks module identity (renderer'sfetch/Responseglobals silently drop out of scope).- Loaders in
packages/scripts/config/webpack.config.jsmust berequire.resolve'd, not bare strings — webpack resolves loader strings against the consuming app's directory, which only ever worked under npm's flat hoisting. kitchen-sink/reactis"type": "module", so its webpack config iswebpack.config.cjs;config/paths.jsprefers a.cjssibling.- Root
.npmrcsetsnode-options="--import tsx"(so.mtsconfig is runnable) andstrict-peer-dependencies=false. - TypeScript is 7.x everywhere,
docsincluded -- which costdocsits type checking:astro checkruns on the compiler's JS API, and@astrojs/language-serverthrows inassertCompatibleTypeScripton 7, so the script and@astrojs/checkare gone (docs/package.jsonhas the note).astro buildis the remaining gate there. Nothing may reintroducerequire('typescript')orresolve.sync('typescript')— 7 has no CJS entry — and the options it removed (target: ES5,esModuleInterop: false,baseUrl) can't come back into a tsconfig. Everything else in the workspace is on latest. react-unity-scripts builddoes not fail on type errors — fork-ts-checker is gone, soTSC_COMPILE_ON_ERRORdoes nothing andpnpm typecheckis what catches them. packages/scripts/config/modules.js derives webpack'ssrcalias frombaseUrl, and accepts"paths": { "*": ["./*"] }as the same thing since TS 7 removedbaseUrl;unity/core/.react/devtoolsrelies on that for itssrc/…imports.pnpm-workspace.yaml's dependency-build allowlist isallowBuilds, not pnpm 10'sonlyBuiltDependencies. pnpm 11 still accepts the old key —pnpm config listechoes it back — but no longer consults it, so every install script silently gets skipped. Combined with pnpm 11 defaultingstrictDepBuildsto true, that turns a skipped build intoERR_PNPM_IGNORED_BUILDSand fails the install. Packages are listed explicitly astrueorfalse; omitting one leaves it "undecided", which is whatstrictDepBuildserrors on. Only four aretrue— the ones whose native or downloaded binaries never materialise otherwise. Anything whose install script just prints a funding banner goes in asfalse.
Biome 2 (biome.jsonc) is the only JS/TS formatter and linter: single quotes, 2-space indent, width 140, LF, reactClassic JSX runtime for the formatter. Two settings there exist because of what this repo is, and both carry comments: a11y is off (every JSX file Biome sees renders Unity components, not DOM — <button> has no type, <image> has no alt), and the CSS noUnknown* rules are off (ReactUnity's CSS dialect is not the web's). docs/ is excluded and uses its own Prettier setup — it formats .astro files, which Biome cannot. C# formatting comes from the root .editorconfig.
Line endings are LF everywhere, enforced at two levels: .gitattributes normalises on commit (* text=auto eol=lf), so no editor can put a CRLF into the repo whatever Visual Studio or Unity write on disk, and .editorconfig asks editors for LF so the churn never starts. Both carry comments explaining the state they replaced — before them the repo was 1921 LF against 1015 CRLF, split within every extension. *.bat/*.cmd are the one declared CRLF exception, and *.asset is deliberately left to git's content sniffing (one is binary lighting data among 136 YAML ones).
There is one .editorconfig, at the root. There were fourteen — one per merged repo, each declaring root = true, so each subtree was governed by its own copy and eleven of them were byte-identical; the only thing the split achieved was disagreeing about line endings. Repo-wide editor rules go in the root file. The exception is packages/create/scaffold/react/.editorconfig, which is shipped to scaffolded user projects (its .npmignore un-ignores it deliberately) rather than configuring this repo.
Keep comments short. About one line inside a function body, about three for public API documentation. Comment the non-obvious decision — what was tried and why it was rejected — but compress it to a sentence and state the conclusion rather than narrating how you got there. Longer reasoning belongs in the commit message or the .tegami changelog entry, not the source.