Global singleton state
arrdem opened this issue · 6 comments
In reading this library, one thing stuck out to me like a sore thumb: every single facet of your CES is stored inside a set of globally shared atoms. This means that it's impossible to do simulations in your CES, it's impossible to have more than one CES at once... and to boot the global singleton pattern is a code smell in Clojure.
this is the CES that I built previously. The great advantage of modeling a CES this way, is that you can drop down to using a single shared ref if you have to, and when you don't, you can extract the value, update it functionally, and play with it as you see fit.
Hmnnn. I totally hear what you are saying!
I see you are passing around ces
as an argument in your ces implementation (I assume it's just a map?), which gives you much greater flexibility, you can create multiple CES instances at once, and it makes doing lots of things a lot easier.
I have actually written Clojure code like that before (and prefer it), but shied away from it here, because it didn't feel as 'library-esque' to force essentially be passing around global state.
I'm going to let this roll around in my head for a bit, but I'm leaning in your direction at the moment. Sure it's an API breakage, but this library is v0, so that's okay 😄
My initial thoughts would be to have something akin to (writing this off the top of my head):
(ns brute.entity)
(defn create-system
"Creates an entity system state"
[]
{:all-entities (ref #{})
:entity-components (ref {})
:entity-component-types (ref {})})
Which basically mimics my current global singleton state, but in a manner than can be passed around. I like having the multiple refs, as it allows for fast lookup and discovery.
From there, things like create-entity!
just become:
(defn create-entity!
"Creates an entity and stores it"
[system]
(let [entity (java.util.UUID/randomUUID)]
(dosync
(alter (:all-entities system) conj entity)
(alter (:entity-component-types system) assoc entity #{}))
entity))
...and so on through the API.
Thoughts?
Btw - thanks for the feedback. As I said, this is my first Clojure library, so it's been a great learning experience.
No worries. I have some other code that illustrates the syntactic limitations of this approach, and involving refs at all turns out to be uncalled for thanks to the install
and resolve
functions, which I suggest that you play with.
Basically what install
does is it takes some higherarchical, nested datstructure, say
(def player
{:name "Jerry"
:location {:x 5 :y 3}
:inventory [{:type :sock :attrs #{}}]
})
and "decomposes" it into member single layer maps and vectors. So the decompose of this datastructure would be a pair [(uuid) {:type :sock :attrs #{}}]
, [(uuid) {:x 5 :y 3}]
and [(uuid) {:name "Jerry" :location <uuid> :inventory [<uuid>]}
. These decompose results are then installed in the CES, so really you get a new map like this:
(let [uuid_0 (uuid) uuid_1 (uuid) uuid_2 (uuid)]
{uuid_0 {:type :socks :attrs #{}}
uuid_1 {:x 5 :y 3}
uuid_2 {:name "Jerry" :location uuid_1 :inventory [uuid_0]}
})
What this lets you do is view your data two ways: either as a bunch of UUID's components in a structure, or thanks to the resolve
function, you can reconstruct that original player map, with metadata indicating the UUID of each element of the original player. What this lets you do, is you can now think about selecting a single entire entity from the CES, introspecting and updating it, and then the metadata lets you install it again right where it was.
I will urge you to look away from atoms and refs. Except in cases where there is a clear need to share state between multiple threads, refs and atoms are almost universally the wrong tool for the job. For instance, with this map CES representation, realize that I can chain updaters just as happily by performing (-> my-ces (updater-one) (updater-two) (updater-three))
, all of which can perform functional updates to the CES datastructure requiring no mutable internal state via refs or atoms.
I will urge you to look away from atoms and refs.
I was thinking just that right as soon as I hit submit on my last comment.
You really just want to be passing in a immutable data structure into a function and then returning a new data structure which has the change in it.
So the flow becomes something more like:
(defn create-ball
[system]
(let [entity (e/create-entity) ; just returns a UUID, doesn't do anything else with it
system (e/add-entity system entity) ; returns the system with the entity attached
system (e/add-component system (Ball.)] ; returns the system with the instance of the Ball component attached to the entity
system) ; spit it back out the other side
When actually writing real code, you wouldn't need all the let statements, but I'm just being really explicit so I can write and comment it all out (easy to make much cleaner with threading macros)
Thanks for taking the time to explain, that's given me a great deal to think about. It's quite likely I'll rewrite this whole thing now 🤘
Welcome to clojure, the land where we weren't joking about that whole immutability thing 👊
As you say, threading macros make this look pretty nice:
(defn create-ball
[system]
(-> system
(e/add-entity (e/create-entity))
(e/add-component (Ball.))))
Fixed in develop
branch. Now writing a blog post explaining why this is better, and then releasing.