Skip to content
Saikyun edited this page Jun 10, 2020 · 38 revisions

Arcadia is different from both Unity and Clojure in important ways. Knowledge of both is important, but so is understanding how Arcadia itself works.

Hooks and State

Unity will notify GameObjects when particular events occur, such as collisions with other objects or interaction from the user. Most of Unity's events are listed here.

In C# Unity development, you specify how to respond to events by implementing Component classes implementing event methods, and attaching instances of these classes to GameObjects.

Arcadia provides a simple and consistent associative (key-based) interface into Unity's event system. Users can process the scene graph as data and transparently converting events and state to Clojure's persistent maps and back. Rather than defining Component classes, Arcadia users associate callback functions (referred to as hooks) with GameObjects and Unity events.

This comprises a system of related functions:

function description
(hook obj, event-kw, role-key) Retrieves a message callback (hook) from GameObject obj on a key
(hook+ obj, event-kw, role-key, IFn) Sets a message callback on a key
(hook- obj, event-kw, role-key) Removes a message callback on a key
(state obj, role-key) Retrieves a piece of state from obj on a key
(state+ obj, role-key, state-map) Sets a piece of state on a key
(state- obj, role-key) Removes a piece of state on a key
(update-state obj, role-key, f & args) Updates the state on a key by applying function f to it
(role obj, role-key) Retrieves a map containing all hooks and state on a key
(role+ obj, role-key, role-map) Sets state and multiple hooks on a key
(role- obj, role-key) Removes state and all hooks on a key
(roles obj) Retrieves mapping from all keys on obj to the role-map for a given obj
(roles+ obj, roles-map) Sets multiple roles at once (may remove hooks or state)

Hooks are functions (anything that implements clojure.lang.IFn). When their corresponding Unity event fires, they are passed two or more arguments:

  1. The GameObject they are attached to
  2. The role-key value that was used when the hook was attached
  3. Additional arguments depending on the Unity event

For example, Unity's Update event provides no additional arguments, so an :update hook would take two arguments

(use 'arcadia.core)

(defn log-name [obj role-key]
  (log (.name obj)))

(let [the-object (new UnityEngine.GameObject "some object")]
  (hook+
    the-object
    :update
    :log-name
    ;; in log-name `obj` will be the `the-object`, `role-key` will be `:log-name`
    #'log-name))

Unity's OnCollisionEnter event provides a single argument of type Collision that describes the collision that occurred, an :on-collision-enter hook would take three arguments:

(defn log-collision [obj role-key collision] 
  (log "just bumped into" (.. collision gameObject name)))

(let [the-object (new UnityEngine.GameObject "some object")]
  (hook+
    the-object
    :on-collision-enter
    :log-collision
    ;; in log-collision `obj` will be the `the-object`, `role-key` will be
    ;; `:log-collision`, `collision` will be the Collision instance provided
    ;; by Unity
    #'log-collision))
defmutable

The hook system is complemented by defmutable, a type-definition mechanism for high-performance mutable state:

form description
(defmutable OrbitState [^float radius]) Defines mutable type with unboxed primitive field radius
(snapshot orbit-state) Retrieves persistent representation of defmutable-type instance orbit-state
(mutable orbit-state-map) Constructs defmutable-type instance from persistent representation orbit-state-map

roles will snapshot any defmutable-type instances in a GameObject's state, and role+ will convert any defmutable snapshot data to a corresponding defmutable instance. Users can thereby process the scene graph as immutable data without sacrificing imperative performance.

The hook system is fully optional and can coexist with other approaches. If you want to roll your own treatment of the scene graph Arcadia will not get in your way.

Usage Example
(use 'arcadia.core 'arcadia.linear)
(import '[UnityEngine GameObject Time Mathf Transform])

(defn orbit [^GameObject obj, k]         ; Takes the GameObject and the key this function was attached with
  (let [{:keys [:radius]} (state obj k)] ; Looks up the piece of state corresponding to the key `k`
    (with-cmpt obj [tr Transform]
      (set! (. tr position)
        (v3 (* radius (Mathf/Cos Time/realtimeSinceStartup))
            0
            (* radius (Mathf/Sin Time/realtimeSinceStartup)))))))

(let [gobj (create-primitive :cube "Orbiter")]
  (state+ gobj :orbit {:radius 5})          ; set up state
  (hook+ gobj :fixed-update :orbit #'orbit) ; set up message callback (hook)
  )

The role system can abbreviate this and treat it as data:

(let [gobj (create-primitive :cube "Orbiter")]
  (role+ gobj :orbit
    {:state {:radius 5}
     :fixed-update #'orbit}))

role+ is intended to emulate assoc with the GameObject in the place of a map, and will therefore replace any hooks and state already on the GameObject at the specified key.

(defn orbit-collide [obj1 obj2 k]
  (UnityEngine.Debug/Log "a collision!"))

;; We'll start by setting up a role.
(role+ (object-named "Orbiter") :orbit
  {:state {:radius 5},
   :fixed-update #'orbit
   :on-collision-enter #'orbit-collide})

(role (object-named "Orbiter") :orbit)

;; =>
;; {:state {:radius 5},
;;  :fixed-update #'orbit
;;  :on-collision-enter #'orbit-collide}

(defn other-orbit-collide [obj1 obj2 k]
  (UnityEngine.Debug/Log "another collision!"))

;; If we call `role+` again with the same key but a different map of
;; hooks and state, it will:
;; - reset hooks and state to the specified values
;; - remove any existing hooks or state not specified
(role+ (object-named "Orbiter") :orbit
  {:state {:radius 8},
   :on-collision-enter #'other-orbit-collide})

(role (object-named "Orbiter") :orbit)

;; =>
;; {:state {:radius 8},
;;  :on-collision-enter #'other-orbit-collide}

;; Note that the :fixed-update hook has been removed.

Attached state and roles can then be retrieved as persistent maps using role:

  (role (object-named "Orbiter") :orbit)

  ;; =>
  ;; {:state {:radius 5},
  ;;  :fixed-update #'user/orbit}

Multiple callbacks and pieces of state may be attached in this manner.

;; ...
(defn bobble [^GameObject obj, k] ; Takes the GameObject and the key this function was attached with
  (let [{:keys [:amplitude]} (state obj k)] ; Looks up the piece of state corresponding to the key `k`
    (with-cmpt obj [tr Transform]
      (set! (. tr position)
        (v3 (.. tr position x)
            (* amplitude (Mathf/Cos Time/realtimeSinceStartup))
            (.. tr position y))))))

(let [gobj (create-primitive :cube "Orbiter")]
  (state+ gobj :orbit {:radius 5})          ; set up state
  (hook+ gobj :fixed-update :orbit #'orbit) ; set up the hook
  (state+ gobj :bobble {:amplitude 5})      ; set up another piece of state
  (hook+ gobj :fixed-update #'bobble)       ; set up another hook
  )

All keys can be accessed and managed at once using roles and roles+.

roles bundles up all the keys, hooks, and state into a map with the hook or state keys as its keys, and maps suitable for use in role+ as its values.

  (roles (object-named "Orbiter"))

  ;; =>
  ;; {:orbit {:state {:radius 5},
  ;;          :fixed-update #'orbit},
  ;;  :bobble {:state {:amplitude 5},
  ;;           :fixed-update #'bobble}}

roles+ attaches a bundle of keys, hooks, and state, such as might be returned by roles, to the scene graph.

  (roles+ (object-named "Orbiter")
    {:orbit {:state {:radius 5},
             :fixed-update #'orbit},
     :bobble {:state {:amplitude 5},
              :fixed-update #'bobble}})
Why use Vars rather than functions?

While anonymous fn functions and other IFns can be used as hooks, Vars are greatly preferred because they can be serialized. Without serialization, hooks cannot be:

  • Cloned using instantiate
  • Displayed in the inspector
  • Saved into a scene
  • Saved into a prefab

Due to these limitations, Arcadia will by default issue a warning to the console if you attach a hook to an object that is not a var. This behavior can be controlled in your configuration file as documented in the Configuration page of the wiki.

Any data that would be closed over by an anonymous function instance can be stored in the state corresponding to a callback Var. For example, consider the following function that takes a GameObject and a numeric rate of spin, and starts the GameObject spinning at that rate.

(defn start-spinning [obj, rate]
  (let [f (fn [obj, _]
            (with-cmpt obj [tr Transform]
              (set! (.. tr rotation)
                (qq* (.. tr rotation)
                     (aa rate 0 1 0)))))] ; uses `rate` to set degrees spun per frame
    (role+ obj ::gyrate
      {:update f})))

Here we attach an anonymous fn to obj as the :update hook. Anonymous fn's cannot serialize, therefore this GameObject cannot serialize, greatly limiting its role in our scene as described above.

We can solve this problem by boosting the :update hook into a top-level named function, and storing rate in the state:

(defn spin [obj, k]
  (let [{:keys [rate]} (state obj k)] ; retrieve rate of spinning from state
    (with-cmpt obj [tr Transform]
      (set! (.. tr rotation)
        (qq* (.. tr rotation)
             (aa rate 0 1 0))))))

(defn start-spinning [obj, rate]
 (role+ obj ::gyrate
   {:update #'spin        ; attach the Var #'spin as an update hook
    :state {:rate rate}}) ; store rate of spinning in state
 )

This arrangement will serialize correctly.

Entry Point

Clojure developers often look for a "main" function or an "entry point" that they can use to kick off their code. Unity does not have such a thing. All code at runtime is triggered through Messages, meaning all code at runtime is triggered by Components attached to GameObjects. If your game logic would benefit from an entry point, you will have to add an empty GameObject to the scene with a :start hook associated with the function you want to run at startup.

Resources/Load during serialization

It's good to know is that any code loaded by a hook attached to a game object will be run during deserialization. This means that if you refer to a namespace that runs Resources/Load when it's loaded, e.g. by using def: (def x (Resources/Load "x")), then you will get this error: UnityException: Load is not allowed to be called during serialization, call it from Awake or Start instead. Called from MonoBehaviour 'AwakeHook' on game object 'keyboard'. The way to solve this is to only call Resources/Load in :start, :update, :awake or similar hooks. One way to do this is to create an init-function which gets called in a :start-hook, and that in turn runs the required Resources/Loads. E.g.

(def x nil)

(defn init
  []
  (def x (Resources/Load "x")))

This might not look that pretty, but it works. You could also do something like having a resources map, and update it:

(def resources (atom {}))

(defn add-res!
  [res]
  (swap! resources assoc res (Resources/Load res)))

(init
  []
  (add-res! "x"))

A bit prettier, but more code.

Storing a Start-hook in the scene

For hooks that serialize and persist, writing code to a file is crucial. At the same time, the UI for adding hooks is not finished yet, so the REPL is still needed. A good workflow is the following:

  • Start a Clojure file in Assets following Clojure folder naming conventions (e.g. Assets/game/core.clj)
  • Give the file a namespace form as usual with any requires you need
(ns game.core
  (:use arcadia.core))
  • Define a function in that file that you want to use as a hook
(defn rotate [obj role-key]
  (.. obj transform (Rotate 0 1 0)))
  • Save the file
  • In the REPL, require the namespace and use arcadia.core
user=> (require 'game.core)
user=> (use 'arcadia.core)
  • In the REPL, attach the var to a GameObject with the appropriate event keyword and a role keyword
user=> (hook+ (object-named "Main Camera") :update :rotation #'game.core/rotate)
  • Save the scene

ClojureCLR

Most Clojure programmers are familiar with the JVM-based version of the language, but Arcadia does not use that. Instead, it is built on the official port to the Common Language Runtime that David Miller maintains. We maintain our own fork of the compiler so that we can introduce Unity specific fixes.

As an Arcadia programmer you should be aware of the differences between ClojureCLR and ClojureJVM, and the ClojureCLR wiki is a good place to start, in particular the pages on interop and types. Our wiki page on "gotchas" is a good resource on implementation-specific bugs/surprises and their workarounds.

Unity Interop

Arcadia does not go out of its way to "wrap" the Unity API in Clojure functions. Instead, a lot of Arcadia programming bottoms out in interoperating directly with Unity. For a function to point the camera at a point in space could look like

(defn point-camera [p]
  (.. Camera/main transform (LookAt p)))

This uses Clojure's dot special form to access the static main field of the Camera class, which is a Camera instance and has a transform property of type Transform that has a LookAt method that takes a Vector3. It is the equivalent of Camera.main.transform.LookAt(p) in C#.

Unity is a highly mutable, stateful system. The above function will mutate the main camera's rotation to look at the new point. Furthermore, the reference to Camera/main could be changed by some other bit of code. Unity's API is single-threaded by design, so memory corruption is avoided. Your own Clojure code can still be as functional and multi-threaded as you like, but keep in mind that using the Unity API has side effects and is impure.

For convenience, there are parts of the API that we have wrapped in Clojure functions and macros. These are usually very commonly used methods whose terseness would improve the experience at the REPL. The scope of what does and does not get wrapped is an ongoing design exercise of the framework, but in general we plan to be conservative about how much of our own ideas we impose on top of the Unity API.

Multithreading

While the Unity scene graph API is ostensibly single threaded, the Mono VM it runs on is not. This means you can write multithreaded Clojure code without issue provided that you do not call Unity scene graph methods off the main thread. If you do, they will throw an exception. REPL code is evaluated on the main thread, and the hook/state system can only be used from the main thread.

Note that not all types in the UnityEngine namespace need to be used from the main thread. Some value types such as Vector3 are usable anywhere, for example.

Namespace Roots

The Assets folder is the root of your namespaces. So a file at Assets/game/logic/hard_ai.clj should correspond to the namespace game.logic.hard-ai.

In addition to that you can specify additional namespace roots in your configuration.edn file using the :source-paths key. It expects a vector of strings that describe paths relative to Assets to act as additional namespace roots. These paths take precedence over Assets for the purposes of namespace resolution.

project.clj

Arcadia supports sub-projects that contain a Leiningen-formatted project.clj file. Folders under Assets that contain such a file will have their src subdirectory added as namespace root, if such a subdirectory exists. Additionally, if the project.clj file declares any :source-paths those will be added as namespace roots as well.

VM Restarting

Unity will restart the Mono VM at specific times

  • Whenever the editor compiles a file (e.g. when a .cs file under Assets is updated or a new .dll file is added)
  • Whenever the editor enters play mode

When this happens, your REPL connection will be interrupted and you will have to reconnect. Any state you had set up in the REPL will be lost and you will have to re-require any namespaces you were working with. This is a deep part of Unity's architecture and not something we can meaningfully affect, unfortunately.

Exporting

To make your game playable outside of Unity you have to export it. This is a three step process:

  1. Declare the Clojure namespaces you intend to include in your exported game in the :export-namespaces key in your configuration.edn file. The expected syntax is a vector of symbols where each symbol corresponds to a namespace. Each namespace will be compiled along with every namespace it requires or uses. Make sure not to include namespaces that refer to any types under UnityEditor as they will not be usable in export, e.g. arcadia.debug.
  2. Click Arcadia → Prepare for Export. This will compile your Clojure code to .dll files that Unity can export. Arcadia is in an indeterminate state after you've done this, so proceed immediately to step 3.
  3. Click File → Build Settings... and build your Unity game as normal. The compiled Arcadia files will be automatically cleaned up.
Clone this wiki locally