I watched Stuart Sierra's talk "Thinking In Data" and took one of the ideas from it as a design principle in this game I'm making. The difference is he's working in Clojure and I'm working in JavaScript. I see some major differences between our languages in that:
- Clojure is idiomatically functional programming
- Most state is immutable
I took the idea from the slide "Everything is a Map" (From 11 minutes, 6 seconds to > 29 minutes in). Some things he says are:
- Whenever you see a function that takes 2-3 arguments, you can make a case for turning it into a map and just passing a map in. There are a lot of advantages to that:
- You don't have to worry about argument order
- You don't have to worry about any additional information. If there are extra keys, that's not really our concern. They just flow through, they don't interfere.
- You don't have to define a schema
- As opposed to passing in an Object there's no data hiding. But, he makes the case that data hiding can cause problems and is overrated:
- Performance
- Ease of implementation
- As soon as you communicate over the network or across processes, you have to have both sides agree on the data representation anyway. That's extra work you can skip if you just work on data.
Most relevant to my question. This is 29 minutes in: "Make your functions composable". Here's the code sample he uses to explain the concept:
;; Bad (defn complex-process [] (let [a (get-component @global-state) b (subprocess-one a) c (subprocess-two a b) d (subprocess-three a b c)] (reset! global-state d))) ;; Good (defn complex-process [state] (-> state subprocess-one subprocess-two subprocess-three))
I understand the majority of programmers aren't familiar with Clojure, so I'll rewrite this in imperative style:
;; Good def complex-process(State state) state = subprocess-one(state) state = subprocess-two(state) state = subprocess-three(state) return state
Here are the advantages:
- Easy to test
- Easy to look at those functions in isolation
- Easy to comment out one line of this and see what the outcome is by removing a single step
- Each subprocess could add more information on to the state. If subprocess one needs to communicate something to subprocess three, it's as simple as adding a key/value.
- No boilerplate to extract the data you need out of the state just so that you can save it back in. Just pass in the whole state and let the subprocess assign what it needs.
Now, back to my situation: I took this lesson and applied it to my game. That is, almost all of my high level functions take and return a gameState
object. This object contains all the data of the game. EG: A list of badGuys, a list of menus, the loot on the ground, etc. Here's an example of my update function:
update(gameState)
...
gameState = handleUnitCollision(gameState)
...
gameState = handleLoot(gameState)
...
What I'm here to ask about is, have I created some abomination that perverted an idea that is only practical in a functional programming language? JavaScript isn't idiomatically functional (though it can be written that way) and it's really challenging to write immutable data structures. One thing that concerns me is he assumes that each of those subprocesses are pure. Why does that assumption need to be made? It's rare that any of my functions are pure (by that, I mean they often modify the gameState
. I don't have any other complicated side effects other than that). Do these ideas fall apart if you don't have immutable data?
I'm worried that one day I'll wake up and realize this whole design is a sham and I've really just been implementing the Big Ball Of Mud anti-pattern.
Honestly, I've been working on this code for months and it's been great. I feel like I'm getting all the advantages he's claimed. My code is super easy for me to reason about. But I'm a one man team so I have the curse of knowledge.
Update
I've been coding 6+ months with this pattern. Usually by this time I forget what I've done and that's where "did I write this in a clean way?" comes into play. If I haven't, I'd really struggle. So far, I'm not struggling at all.
I understand how another set of eyes would be necessary to validate its maintainability. All I can say is I care about maintainability first and foremost. I'm always the loudest evangelist for clean code no matter where I work.
I want to reply directly to those that already have a bad personal experience with this way of coding. I didn't know it then, but I think we're really talking about two different ways of writing code. The way I've done it appears to be more structured than what others have experienced. When someone has a bad personal experience with "Everything is a map" they talk about how hard it is to maintain because:
- You never know the structure of the map that the function requires
- Any function can mutate the input in ways you'd never expect. You have to look all over the code base to find out how a particular key got into the map or why it disappeared.
For those with such an experience, perhaps the code base was, "Everything takes 1 of N types of maps." Mine is, "Everything takes 1 of 1 type of map". If you know the structure of that 1 type, you know the structure of everything. Of course, that structure usually grows over time. That's why...
There's one place to look for the reference implementation (ie: the schema). This reference implementation is code the game uses so it can't get out of date.
As for the second point, I don't add/remove keys to the map outside of the reference implementation, I just mutate what's already there. I also have a large suite of automated tests.
If this architecture eventually collapses under its own weight, I'll add a second update. Otherwise, assume everything is going well :)