O'Doyle does indeed rule. And you will, too, when you use O'Doyle Rules, a rules engine for Clojure and ClojureScript. Stop being one of those jabronis that don't rule things. When I was a kid in Virginia our teacher tried to teach us the names of cities in our state by letting us be the "rulers" of them. My buddy Roger said he ruled Richmond. I said I ruled Chicago because I was a Bulls fan and didn't understand geography. I don't recall why I'm telling you this.
O'Doyle is different than Clara in a few ways.
Advantages compared to Clara:
[::player ::health 10]
whereas Clara (by default) uses Clojure records. I think storing each key-value pair as a separate fact leads to a much more flexible system.ruleset
macro that defines your rules from a map of plain data. Clara's defrule
macro creates a global var that is implicitly added to a session. I tried to solve that particular problem with my clarax library but with O'Doyle it's even cleaner.defquery
macro for making queries, which means potential duplication since queries can often be the same as the "left hand side" of a rule.Disadvantages compared to Clara:
The design of O'Doyle is almost a carbon copy of my Nim rules engine, pararules.
Let's start by just making a rule that prints out a timestamp whenever it updates:
(require '[odoyle.rules :as o])
(def rules
(o/ruleset
{::print-time
[:what
[::time ::total tt]
:then
(println tt)]}))
;; create session and add rule
(def *session
(atom (reduce o/add-rule (o/->session) rules)))
The most important part of a rule is the :what
block, which specifies what tuples must exist for the rule to fire. The key is that you can create a binding in the id or value column by supplying a symbol, like tt
above. When the rule fires, the then
block is executed, which has access to the bindings you created.
You can then insert the time value:
(swap! *session
(fn [session]
(-> session
(o/insert ::time ::total 100)
o/fire-rules)))
The nice thing is that, if you insert something whose id + attribute combo already exists, it will simply replace it.
Now imagine you want to make the player move to the right every time the frame is redrawn. Your rule might look like this:
(def rules
(o/ruleset
{::move-player
[:what
[::time ::total tt]
:then
(-> o/*session*
(o/insert ::player ::x tt)
o/reset!)]}))
The *session*
dynamic var will have the current value of the session, and `reset! will update it so it has the newly-inserted value.
Updating the player's ::x
attribute isn't useful unless we can get the value externally to render it. To do this, make another rule that binds the values you would like to receive:
(def rules
(o/ruleset
{::move-player
[:what
[::time ::total tt]
:then
(-> o/*session*
(o/insert ::player ::x tt)
o/reset!)]
::get-player
[:what
[::player ::x x]
[::player ::y y]]}))
As you can see, rules don't need a :then
block if you're only using them to query from the outside. In this case, we'll query it externally and get back a vector of maps whose fields have the names you created as bindings:
(swap! *session
(fn [session]
(-> session
(o/insert ::player ::x 20)
(o/insert ::player ::y 15)
o/fire-rules)))
(println (o/query-all @*session ::get-player))
;; => [{:x 20, :y 15}]
You can also query from inside a rule with the special *session*
dynamic var:
(def rules
(o/ruleset
{::get-player
[:what
[::player ::x x]
[::player ::y y]]
::print-player-when-time-updates
[:what
[::time ::total tt]
:then
(println "Query from inside rule:" (o/query-all o/*session* ::get-player))]}))
(reset! *session
(-> (reduce o/add-rule (o/->session) rules)
(o/insert ::player {::x 20 ::y 15}) ;; notice this short-hand way of inserting multiple things with the same id
(o/insert ::time ::total 100)
o/fire-rules))
Imagine you want to move the player's position based on its current position. So instead of just using the total time, maybe we want to add the delta time to the player's latest ::x
position:
(def rules
(o/ruleset
{::get-player
[:what
[::player ::x x]
[::player ::y y]]
::move-player
[:what
[::time ::delta dt]
[::player ::x x {:then false}] ;; don't run the :then block if only this is updated!
:then
(-> o/*session*
(o/insert ::player ::x (+ x dt))
o/reset!)]}))
(reset! *session
(-> (reduce o/add-rule (o/->session) rules)
(o/insert ::player {::x 20 ::y 15})
(o/insert ::time {::total 100 ::delta 0.1})
o/fire-rules))
(println (o/query-all @*session ::get-player))
;; => [{:x 20.1, :y 15}]
The {:then false}
option tells O'Doyle to not run the :then
block if that tuple is updated. If you don't include it, you'll get a StackOverflowException because the rule will cause itself to fire in an infinite loop. If all tuples in the :what
block have {:then false}
, it will never fire.
Another way to avoid infinite loops is to not include the data you're modifying in your :what
block at all. Instead, you could retrieve it by querying from your :then
block with (o/query-all o/*session* ::get-player)
and pulling out the data you want.
Rules have nice a way of breaking apart your logic into independent units. If we want to prevent the player from moving off the right side of the screen, we could add a condition inside of the :then
block of ::move-player
, but it's good to get in the habit of making separate rules.
To do so, we need to start storing the window size in the session. Wherever your window resize happens, insert the values:
(defn on-window-resize [width height]
(swap! *session
(fn [session]
(-> session
(o/insert ::window {::width width ::height height})
o/fire-rules))))
Then we make the rule:
::stop-player
[:what
[::player ::x x]
[::window ::width window-width]
:then
(when (> x window-width)
(-> o/*session*
(o/insert ::player ::x window-width)
o/reset!))]
Notice that we don't need {:then false}
this time, because the condition is preventing the rule from re-firing.
While the above code works, you can also put your condition in a special :when
block:
::stop-player
[:what
[::player ::x x]
[::window ::width window-width]
:when
(> x window-width)
:then
(-> o/*session*
(o/insert ::player ::x window-width)
o/reset!)]
You can add as many conditions as you want, and they will implicitly work as if they were combined together with and
:
::stop-player
[:what
[::player ::x x]
[::window ::width window-width]
:when
(> x window-width)
(pos? window-width)
:then
(-> o/*session*
(o/insert ::player ::x window-width)
o/reset!)]
Using a :when
block is better because it also affects the results of query-all
-- matches that didn't pass the conditions will not be included. Also, in the future I'll probably be able to create more optimal code because it will let me run those conditions earlier in the network.
Instead of the ::get-player
rule, we could make a more generic "getter" rule that works for any id:
::get-character
[:what
[id ::x x]
[id ::y y]]
Now, we're making a binding on the id column, and since we're using the same binding symbol ("id") in both, O'Doyle will ensure that they are equal, much like a join in SQL.
Now we can add multiple things with those two attributes and get them back in a single query:
(reset! *session
(-> (reduce o/add-rule (o/->session) rules)
(o/insert ::player {::x 20 ::y 15})
(o/insert ::enemy {::x 5 ::y 5})
o/fire-rules))
(println (o/query-all @*session ::get-character))
;; => [{:id :examples.odoyle/player, :x 20, :y 15} {:id :examples.odoyle/enemy, :x 5, :y 5}]
In addition to keywords like ::player
, it is likely that you'll want to generate ids at runtime. For example, if you just want to spawn random enemies, you probably don't want to create a special keyword for each one. For this reason, insert
allows you to just pass arbitrary integers as ids:
(swap! *session
(fn [session]
(o/fire-rules
(reduce (fn [session id]
(o/insert session id {::x (rand-int 50) ::y (rand-int 50)}))
session
(range 5)))))
(println (o/query-all @*session ::get-character))
;; => [{:id 0, :x 14, :y 45} {:id 1, :x 12, :y 48} {:id 2, :x 48, :y 25} {:id 3, :x 4, :y 25} {:id 4, :x 39, :y 0}]
Notice that we've been using qualified keywords a lot. What else uses qualified keywords? Spec, of course! This opens up a really cool possibility. If you have spec instrumented, and there are specs with the same name as an O'Doyle attribute, it will check your inputs when you insert
. For example:
(require
'[clojure.spec.alpha :as s]
'[clojure.spec.test.alpha :as st])
(st/instrument)
(s/def ::x number?)
(s/def ::y number?)
(s/def ::width (s/and number? pos?))
(s/def ::height (s/and number? pos?))
(swap! *session
(fn [session]
(-> session
(o/insert ::player {::x 20 ::y 15 ::width 0 ::height 15})
o/fire-rules)))
This will produce the following error:
Error when checking attribute :examples.odoyle/width
Syntax error (ExceptionInfo) compiling at (examples\odoyle.cljc:166:1).
-- Spec failed --------------------
0
should satisfy
pos?
clj -A:dev
clj -A:test
clj -A:prod install
I could not have built this without the 1995 thesis paper from Robert Doorenbos, which describes the RETE algorithm better than I've found anywhere else. I also stole a lot of design ideas from Clara Rules.
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close