Skip to content

Chaining Effects with Incremental Dependencies #33

@askvortsov1

Description

@askvortsov1

Hi! I'm working on a snake tutorial, and ran into a design challenge. I was wondering if you had any suggestions for a clean way to implement this in Bonsai.

The premise of my design is that I have 2 "lower-level" stateful computations:

  • Player, which packages a Snake.t (glorified deque of (row,col) coords), direction, score, and status
  • Apple, which packages a Apple.t (just a (row, col) record)

On every tick:

  1. The snake moves
  2. For every apple it eats, the score increases, and the snake's "left_to_grow" param is increased
  3. Every eatten apple is respawned

Additionally, on game restart, every snake and apple is randomly placed on the game grid.

I've attempted several implementations, but none are as clean as I'd like.

Snakes Are in Charge

This is the current code at this commit.

Here, the Player's Move action takes the other elements on the board as parameters. For a game with 1 snake and 1 apple, heres the signature for Player actions:

module Action : sig
  type t =
    | Restart
    | Move of (Apple.t * (Apple.Action.t -> unit Effect.t))
    | Change_direction of Direction.t
end

and for Apple actions:

module Action = struct
  type t =
    | Spawn of Position.t list
    | Eatten of Position.t list
  [@@deriving sexp]
end

On a player's tick, if the snake has eatten the apple, it will use schedule_event to dispatch a Eatten action to the apple, with its current position as an argument.

This becomes messy fast when we add n snakes and m apples. Each player needs to receive all the snakes and apples (and their inject functions!). But because we need to use Effect.Many to combine all these actions into one effect on tick, the ith player could eat an apple causing it to respawn, but the i+1th player would still see the old position of the apple, and of the snakes that have already moved:

  (* Tick logic *)
  let%sub () =
    let%sub clock_effect =
      let%arr player1_inject = player1_inject
      and player2_inject = player2_inject
      and game_elements = game_elements in
      Effect.Many
        [ player1_inject (Move game_elements); player2_inject (Move game_elements) ]
    in
    Bonsai.Clock.every [%here] (Time_ns.Span.of_sec 0.25) clock_effect
  in

This means that the apple could spawn right under where the i+1th snake is about to move, but the i+1th snake would never know.
This also doesn't fix the issue of elements spawning on top of each other when the entire board is reset.

Apples Take The Reins (of their fate)

I tried to fix the generalized snake game in this commit. Essentially:

  • A Game_element.t type represents all Snake.ts and Apple.ts in the game, and comes with a helper to get all occupied positions on the board. I had to separate Apple and Apple_state into separate files to avoid dependency cycles.
  • A Game_element.t is computed incrementally from all Player_state.computations and Apple_state.computations.
  • Apple_state now has a Tick action, which takes that Game_elements.t as input. Apples are responsible for checking if they have been eatten, and for respawning themselves. Players will still separately grow/increase score when they eat an apple.
  • Player_state's Restart and Move actions take that Game_elements.t as input. That way, snakes will not spawn on an existing game element, and will be aware of everything on the board. Apple_state's Spawn and Tick actions do the same

My hope was that each snake would move, check for eatting itself/OOB, and then grow if it ate an apple. Then, all apples would respawn if eatten. Because everything takes Game_elements.t as input, and that Game_elements.t is computed incrementally, the apply_action function would always have an up-to-date view of the world.

Sadly, I can't figure out a way to do this with Bonsai's current API. If I want to dispatch multiple effects at once, I need to combine them with Effect.Many. But that means that all effects are calculated incrementally from Game_elements.t pre-tick, and will not update in response to previous events being evaluated.

I tried Bonsai.lazy_ to compute the effects, but that didn't seem to help. I might be using it incorrectly though.

Input It Out...

As an alternative to all this, we could instantiate computations of snakes and apples in a chain, with each element taking the positions of all previously instantiated elements as an input. That position input could then be used to control where apples/snakes couldn't spawn.

There's 2 big problems with this:

  • I want to leave state machine inputs open for rows and cols, which could be controlled by a form. Having a 3-tuple of inputs would be messy.
  • With this chain approach, the ith apple could never respawn on top of the i-1th apple. However, there's nothing stopping the i+1th apple from respawning on top of the ith apple. I suppose we could use this for full board resets and pass around Game_elements.t through actions for apple respawns in-game, but that feels confusing and messy.

Effect.many_value?

The cleanest solution that comes to my mind would be having some:

val many_value : Effect.t Value.t list -> Effect.t Value.t

that isn't just Value.all, but rather lazily evaluates each effect when its time to call it.
I'm not sure that this would be possible with how Value.t is currently implemented though.

I would also appreciate any suggestions if there's a cleaner / more idiomatic way of doing this. Thanks!

Metadata

Metadata

Assignees

No one assigned

    Labels

    forwarded-to-js-devsThis report has been forwarded to Jane Street's internal review system.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions