How to use MAGIC in a Unity project: compile your Clojure to plugin DLLs with the nos CLI outside Unity, then load them at play time through the magic-unity UPM package. The package also rewrites MAGIC's IL during IL2CPP builds (iOS, Android, consoles).
This is the consumer-side guide. For the package's C# API and install reference, see magic-unity/README.md. For the compile workflow in depth, see Porting a Clojure library to MAGIC.
-
Install
nos(build-time only; needs themonoruntime, no .NET SDK). See Install in the root README. -
Add the package to
Packages/manifest.json, pinned to a tag from the releases page:{ "dependencies": { "sg.flybot.magic.unity": "https://github.com/flybot-sg/magic.git?path=magic-unity#<tag>" } } -
Add
deps-clr.ednandmagic.ednat your project root.deps-clr.edndeclares your source:pathsand any:deps(see declaring CLR dependencies);magic.ednpoints the build at Unity's plugins folder:;; magic.edn {:build {:namespaces [my.game.core] :out "Assets/Plugins/Magic" :csharp-out "Assets/Plugins/CSharp"}}
The two output folders are explained below. A project with custom build/test steps can hand-write a
dotnet.cljinstead; see the porting guide. -
Compile before opening Unity:
nos build
This drops your compiled DLLs into
Assets/Plugins/Magic/, named by the source extension (.clj.dll,.cljc.dll,.cljr.dll), where Unity loads them. A dependency's C# assembly is copied intoAssets/Plugins/CSharp/. -
Write a loader, then Play. Unity doesn't know which DLLs are Clojure or which var is the entry point, so a
MonoBehaviourhas to require and invoke it (pattern:SmokeTestRunner.cs):using Magic.Unity; void Start() { Clojure.Require("my.game.core"); Clojure.GetVar("my.game.core", "start!").invoke(); }
Editor Play mode needs MAGIC as the Editor runtime (see below); otherwise these calls drive ClojureCLR instead.
-
Build a player to exercise the IL2CPP / AOT path. Editor Play runs under Mono and can't surface IL2CPP-only regressions. The smoke example wires this to a one-click menu; see the smoke README. For CI without Unity,
nos testruns the Mono-side tests headless but doesn't exercise IL2CPP.
One folder works: :csharp-out defaults to :out, MAGIC loads the DLLs the same either way, and neither folder name means anything to the tooling. Split them anyway, because Unity treats a file that keeps being deleted differently from one that stays put.
;; magic.edn
{:build {:namespaces [my.game.core]
:out "Assets/Plugins/Magic"
:csharp-out "Assets/Plugins/CSharp"}}Assets/Plugins/Magic/ |
Assets/Plugins/CSharp/ |
|
|---|---|---|
| Set by | :out |
:csharp-out, defaults to :out |
| Holds | your Clojure, compiled | C# assemblies your dependencies ship |
| Written by | nos build, compiling your sources |
nos build, copying files csc compiled long before |
| Wiped every build | yes, by :clean? |
no |
.meta |
Unity writes it on import, then the package's hook constrains it | Unity writes it on import, nothing touches it after |
| Define constraint | !UNITY_EDITOR || MAGIC_RUNTIME_IN_EDITOR, so the Editor loads it only under MAGIC |
none, so it always loads, in both Editor runtimes and every player |
| In git | no, gitignore it | your call, .meta files included |
A library ships only the DLL. Everything else in that table is produced inside your project.
The "wiped every build" row is the reason. :clean? deletes :out before every compile, so an assembly sitting there is imported fresh each time and Unity mints it a new GUID. Anything that referenced the old one, a component on a scene object, another importer's settings, points at nothing. A :csharp-out of its own is never emptied, so the GUID holds.
Whether you commit that folder is a second, separate choice, and it comes down to who runs nos build. Commit it, .meta files included, and a teammate who only opens the Editor (where ClojureCLR compiles your Clojure from source) has the C# plugin without building anything. Gitignore it, like :out, if everyone runs nos build before opening Unity anyway.
A library's C# assembly covers the copy step and the loader namespace a library needs for its C# to resolve at compile time.
The package ships both MAGIC (the default for the player build) and ClojureCLR (the default for the Unity editor); set the Editor runtime via Project Settings > MAGIC, or from a script through Magic.Unity.EditorRuntime (package README). ClojureCLR is the default because it hot-reloads from source; MAGIC-in-Editor is for reproducing player behaviour before a build.
The MAGIC_RUNTIME_IN_EDITOR scripting define symbol leaves exactly one runtime present in every state:
| MAGIC | ClojureCLR | |
|---|---|---|
| Editor, symbol unset (default) | excluded | included |
| Editor, symbol set | included | excluded |
| Player, either | included | excluded |
Note that:
- API Compatibility Level must be
.NET Framework(Project Settings > Player) for ClojureCLR, as it needs assemblies the .NET Standard profile lacks. - In the default state,
Clojure.Require/GetVardrive ClojureCLR, not MAGIC: the same C# calls, executed by whichever runtime the Editor loaded. ClojureCLR compiles from source, so EditorRequireneeds your Clojure sources on its load path (CLOJURE_LOAD_PATH); the DLLs thatnos buildwrote are MAGIC output and stay excluded from the Editor in this state. - Hot reload is yours to wire up. A saved Clojure source can be re-evaluated into the running Editor, but nothing in the package does it: construct a
Magic.Unity.ClojureReloaderover your source roots and poll it from your main-thread loop.
Your compiled .clj.dll / .cljc.dll / .cljr.dll need the same define constraint as the package's MAGIC DLLs; without it a DLL stays Editor-eligible even when the MAGIC runtime is excluded. You normally do nothing about this: for DLLs under Assets/ and in embedded or local packages, the package applies the constraint itself, through two mechanisms. An import callback constrains each DLL as it arrives, and a reconcile pass sweeps every plugin after each domain reload. The pass is what covers DLLs that were already imported when this package version arrived: installing a package does not dirty Assets/, so the import callback never re-runs for them.
flowchart TD
f["A DLL appears or changes<br/>e.g. nos build output"] --> pre["Import callback<br/>looks at that one file"]
c["Domain reload<br/>editor opens, a script changes,<br/>a package is installed"] --> rec["Reconcile<br/>looks at every plugin"]
pre --> gate{"A compiled Clojure DLL<br/>missing the constraint?"}
rec --> gate
gate -->|"under Assets/, or an<br/>embedded/local package"| add["Append the constraint"]
gate -->|"immutable package<br/>(Library/PackageCache)"| warn["Warn: the constraint<br/>must ship in the package"]
gate -->|"already constrained,<br/>or not a Clojure DLL"| skip["Leave it"]
Note
The constraint is appended, never replacing what is already there, so a constraint you set yourself keeps holding (Unity ANDs the entries). When that happens on a DLL the Editor had already loaded, the package requests a script reload and the session converges in place; that first load can print error lines that do not reappear later.
The one case that needs action from you is a DLL inside a package that resolves into the immutable Library/PackageCache (registry, git, or tarball deps): Unity discards .meta writes there, so the constraint has to ship in that package's .meta at publish time. The Editor warns and names the assemblies it could not constrain.
unity-examples/magic-unity-smoke: standalone IL2CPP regression suite, with aMAGIC -> Smoke -> Build & Run IL2CPPmenu. Run by hand on Unity 2022.3.62f3.unity-examples/magic-unity-coexist: headless regression for both Editor-runtime states, driven bybb coexist-noise.