Writing mob AI in Dew
2019-08-20

Expressiveness is what Dew is all about, and I would have no reason other than this to invest time into Dew. I hope by making full use of streams, complex game dynamics could be made easier to implement, or at least so for my own games.

As a language designer, I don’t actually have a precise idea of Dew’s expressiveness, since language design only directly concerns about what and how primitive building blocks goes into the language. To test Dew’s expressiveness, what I would do is to try it out with some problems that I can think of. Sometimes I can recognize some missing feature would be really useful, so I went ahead and added it in; and sometimes Dew’s expressiveness surprises me about what it can do.

Recently, I got an interesting idea from someone in Fediverse. He has a theory that, to test a programming language’s expressiveness, you could try to implement an interpreter in the same language to do what the original language is capable of. For the instance of Dew, the test could then be writing an interpreter to do mob AI. I am not exactly sure whether this expressiveness test makes sense, but I am going to do it anyway since it sounds fun.

A generic mob AI in Dew

In Dew, the type *typ represents a stream, which evaluates to a typ-typed value in each time frame. I chose * to represent stream type because it’s the single most important thing in the language.

Now, an mob AI in Dew could then be simply represented as a function

spawnmob: *observables -> *controls

Note that this function should be called only once when the mob is created, rather than be called multiple times throughout the mob’s lifetime like one would do normally.

The mob can of course need its per-mob states. A mob AI function with states could have the type of

spawnmob_stateful: *observables -> *states -> (*controls,*states)

which accepts the states of its previous frame, and returns the new states of the current frame. For simplicity, we assume the states given to the function at the first frame is zero-initialized.

To implement spawnmob in terms of spawnmob_stateful, we would need to use the well-known pre stream operator. pre a b takes an initial value and a stream, and returns a new stream which always yields the previous value of stream b, except when the first frame it yields a.

let spawnmob observables =
   let state' = pre zero state
   and controls, state = spawnmob_stateful @observables @state' in
   controls

Note that there is not a single type declaration, even though the code is fully statically typed. This power comes from Hindley-Milner type inference.

In normal languages, the function spawnmob_stateful would be implemented as a non-stream function, which gets called every frame.

spawnmob_nonstream: (observables,states) -> (controls,states)

Now spawnmob_stateful can also be written in terms of spawnmob_nonstream:

let spawnmob_stateful observables states' =
   let *(controls, states) = dew ->
      spawnmob_nonstream (@observables, @states') in
   controls, states

Here we see some other Dew stream operators. @expr means to get the current value of stream expr. dew -> expr spawns a stream, whose values over time is defined by evaluating expr once for each frame. The pattern *pat can match against a stream.

Note that here we implement spawnmob in terms of spawnmob_stateful and spawnmob_nonstream for demonstration purposes; one do not actually need to do this in Dew. Within spawnmob, it’s required that internal mob states are eventually implemented as state streams and pre, but one does not need to do this directly and could use some abstractions instead.

Mob AI interpreter

Having an idea how a generic mob AI could be done in Dew, implementing an interpreter is quite straightforward. We would just need to add an additional program parameter to the functions spawnmob, spawnmob_stateful and spawnmob_nonstream.

spawnmob: program -> *observables -> *controls
spawnmob_stateful: program -> *observables -> *states -> (*controls,*states)
spawnmob_nonstream: (program,observables,states) -> (controls,states)

One thing to note however is that states now is the state of the interpreter, which in turns is supposed to encapsulate the states of interpreted program.

Now, I won’t go over the details how an interpreter can actually be implemented, and I am more interested in how the data program can be represented in Dew.

When I started to make Dew, it was intended to serve a single game in my mind, so Dew only has immutable tuples, options and lists. (Note: in case of any confusion, the spec doc on this site has been updated to include more datatypes.)

To represent program, algebraic datatypes(ADT) which are ubiquitous in ML languages would really be useful here to represent tagged unions; and array is another datatype that I miss, which could potentially make the interpreter more efficient. It’s definitely viable to do an interpreter without them, although it would be less elegant or less efficient. Also technically, both tagged unions and array could be emulated to a limited extent with support from game engine.

There’s actually a much more important problem than tagged unions and arrays here, and that is how one can construct cyclic data structures in Dew. This could be useful to represent program, because the jumps in the program could then be implemented as simple references rather than numeric indexes. And it turns out this looks very simple to do in Dew:

let p1 = (1, zero) :: p2
and p2 = (2, zero) :: p3
and p3 = (3, fun () -> p1) :: []

let in Dew is designed to be always recursive. I made it so because it would require fewer keystrokes in a game script. More importantly, the restrictions of what could make into a recursive let are more relaxed from a usual ML language. This is important for Dew, because the program

let x = f x

actually makes sense if x is a stream.

When running the above program, the right hand side of p1, p2, p3 would all be placed in a thunk before evaluating, and when evaluating the thunk of p3, it only needs the reference to the thunk of p1, rather than the evaluated value of it.

The same method can also be applied to more complex situations:

let root = build_tree (fun () -> root)

These all look nice, except there is one problem: we are running into recursive types here, which our Hindley-Milner based type checker would reject. Let’s assume the type of p1 is [a], and by the right hand side of p1, a is then (num, () -> [a]). So we get the type equation

a = (num, () -> [a])

A usual way to do recursive types in a functional language is using ADT, and let a be a named type recty.

type recty = R of (num, () -> [recty])

let p1 = R (1, zero) :: p2
and p2 = R (2, zero) :: p3
and p3 = R (3, fun () -> p1) :: []

# the next line alone does not need to be fixed
let root = build_tree (fun () -> root)

With ADT, the recursive type problem is resolved, so that we can have a nice way to construct cyclic data structures in Dew. I don’t think cyclic data structures are useful for my own game, but we now get one motivation to add ADT nevertheless.

State machines

Now I’d like to think about how I could do a mob AI practically in a real game. I’ve talked about generic ones and those discussion still holds; but in a real game, instead of worrying about making interpreters, I would worry more about modularization, code reusability, etc.

A classical and very useful approach is to introduce state machines, so we can break up the implementation of spawnmob into a few states. So now I’d like to see how a state machine can be written in Dew.

Suppose we use a different function for each state in the state machine, and let’s for a moment forget about state switches. We would probably want the states to be parameterized, and we name the type of the parameters like params1, params2. Then the type of these state functions are:

state1: params1 -> *observables -> *controls
state2: params2 -> *observables -> *controls
...

Now let’s imagine it’s an instance of state_n running, and now it wants to switch to anther state state_m. What we could do in state_n is to call the state_m function with all the needed arguments, and that would get us a *controls stream in return. Instead of getting more values from state_n, we now want to switch to the returned *controls stream from state_m forever.

A naive approach would be to proxy the stream of state_m through the stream of state_n. However, this approach would result in a long chain of states. Each state switch would result in one more state in the state chain, and the value produced by current state function would need be passed through the whole state chain before it’s actually used.

With the current design of Dew, what we should do instead is to return the state switch information, and use an additional runstm function to handle the switch outside of the state functions.

state1: params1 -> *observables -> *(?controls, ?switch)
state2: params2 -> *observables -> *(?controls, ?switch)
...

runstm : *(?controls, ?switch) -> *controls

Type expression ?typ is the option type of typ in Dew, with data constructors ?expr and ?? for Some (expr) and None respectively.

Since the state we switch to now can also switch to another state later, switch should have the type of *(?controls, ?switch), Here we run into recursive types again, so again we have to use an ADT.

In the code below, I also added support for state switch after the current frame. For better lifetime of the stream after the switch in that case, we would need to accept a thunk to instantiate the stream in the next frame, instead of an already-instantiated stream.

type ''a stm = Switch of *(''a stm)
             | SwitchAfter of (''a, () -> *(''a stm))
             | Value of ''a

runstm : *(''a stm) -> *''a

state1: params1 -> *observables -> *(controls stm)
state2: params2 -> *observables -> *(controls stm)
...

I won’t go into the details here about the difference of 'a and ''a in Dew, but ''a is just a normal type variable.

The runstm function could be implemented concisely.

let runstm state0 =
   let switchnow state =
      match @state with
      | Switch s -> s
      | _ -> state in
   let statefn = pre (fun ()->state0) nextstatefn
   and *(ret, nextstatefn) = dew ->
      let state = switchnow (@statefn ()) in
      match @state with
      | Switch _ -> assert false
      | SwitchAfter (v, s) -> v, s
      | Value v -> v, (fun ()->state) in
   ret

So far we’ve got a working state machine in Dew. The state machine is generic, nestable, and each state is represented as a simple function.

Animation with state machines

To test the expressive power of the state machines we just made, let’s consider use them to make some animations.

To make animations, we consider the following combinators.

lift : *''a -> *(''a stm)
map : (''a -> ''b) -> *(''a stm) -> *(''b stm)
concat : *(?''a stm) -> (() -> *(?''a stm)) -> *(?''a stm)
timed : num -> *(?''a stm) -> *(?''a stm)

lift lift a regular stream to a state machine stream, and map maps all values yielded by a state machine stream. timed truncate a given animation to a specified number of frames, where the end of an animation is defined by ??. Finally, concat concatenates two animations.

The implementation is fairly simple:

let timer () =
   let ret = pre 0 (ret+*1) in ret

let lift s =
   dew -> Value @s

let map f s =
   dew -> match @s with
   | Switch s -> Switch (map f s)
   | SwitchAfter (v,s) -> SwitchAfter (f v, map f s)
   | Value v -> Value (f v)

let concat a b =
   dew -> match @a with
   | Value ?? -> Switch b
   | Value ?v -> Value ?v
   | SwitchAfter (v,s) -> SwitchAfter (v, concat s b)
   | Switch s -> concat s b

let timed duration a =
   let timeup = timer () >= duration - *1 in
   map (fun x -> if @timeup then ?? else x) a

*expr as a unary primitive operator turns a value to a stream by repeating it forever. concat only introduces overhead before the former animation ends, and these animation combinators can be used with state machines together.

With the inclusion of recursive types and ADTs in Dew, I am satisfied with the expressive power of state machines we’ve got so far. However, I also see a problem with the state machines: it’s getting a bit too complex for such a basic thing. It is still an elegant datatype with a few powerful combinators, but it takes mental overhead to think whether we are dealing *(''a stm) or *''a. This is important because Dew is intended as a game scripting language, and I want it to be easy to use so the programmer can focus on the game content and have short iteration cycles.

A different approach is to get support from language level, and add a primitive stream operator for the state switch:

switch : *(''a \ *''a) -> *''a

t1\t2\..\tn is the new syntax I come up with for anonymous tagged unions. I like such syntax, since it can help to save keystrokes when writing a game script.

The difference of compiler-implemented switch than a user-implemented one is that the former one would not leave streams behind after the state switch.

Actually, I already have a related primitive operator softref in Dew now, but it’s not powerful enough for state switches.

softref : *bool -> ''a -> *?''a

softref takes a condition stream and a value, and keeps the reference to the value until the condition turns false. switch is strictly more powerful in softref in that, in addition to that we can get rid of the original stream after the state switch, we can replace the output stream with a new stream; whereas softref can only discard the original stream, and the output stream afterwards is useless.

An implementation of softref in terms of switch could be:

let softref cond v =
   switch (dew ->
      if @cond then ?v\
      else \*??)

Conclusion

It turns out recursive types are much more useful than I expected for some of the more complex situations. So I am going to implement ADT in Dew, along with some more datatypes. These datatypes are not exactly too difficult to implement; I only left them out for implementation simplicity. Additionally, I would replace the softref operator with the more powerful switch.

To be clear, state machines and animations are among the first things I’d think about when designing the language. I didn’t notice the problem previously because I got away with either (1) not needing the output of state machines, and using side effects of state machines instead; or (2) ignoring the performance issues of the naive implementation.

Actually, I didn’t expect I could do mobs in my first game. I thought it was hard, and planned to do an empty game world with only the player in it instead. After these thoughts, I’ve decided to do mobs right away — they are actually not difficult to implement with Dew, and they would make a much more alive game world.