Fork me on GitHub
Joshua Suskalo14:11:55

Because it allows you to decouple an idea of what to do with how to do it. Say for example you want to damage your player. You send an event which says [:damage 100 :target :player] or something. Now when you take the damage, it's in the player code that you can check to see if you're currently in invincibility frames, or if the player has a damage-reducing effect, and you can decide whether or not to apply knockback, etc. This has the advantage over doing it manually in every place that you damage the player of reduced code duplication, but also because it keeps code collected in one place the event you want to have different damage handling based on the source (just add a :source kwarg to the event you send)


hmm interesting. maybe this is my bias of only programming turn-based games with limited reaction timing, but i guess i don’t see how that’s different from just having a damage function that does that directly. by having the layer of indirection, you have to now track when things are happening and if/how they’ll be applied

Joshua Suskalo14:11:01

It's also possible (if not likely) that eventually more things than just your target will want to pay attention to that, and only sometimes. Eventually it'll make your damage function very unweildy. Yes you could just have a damage function, but part of the idea here to passing the state of the world but only returning what you're changing (and limiting what any function is allowed to change) is that it prevents the game state from effectively becoming a large set of mutable global variables.

Joshua Suskalo14:11:25

Because if you pass the state map in to every function and return a new version of it from every function, you just have mutable globals with extra steps.

Joshua Suskalo14:11:30

The idea more or less is that you want to contain your manipulation of the world, and ideally make it traceable, that is you can by stepping through your code always know what part of the state may change at any function.

Joshua Suskalo14:11:48

Depending on how you decide to handle events, they can mostly either end up being the only things that mutate the world state, and if you have a fixed point in the frame where that happens it can make it easier to follow, or you can set up the events so that entities can "listen" to them, which means that nothing ever mutates a portion of the state besides itself.

Joshua Suskalo14:11:16

More or less I see the difference between the two as an optimization problem and depends on how many events you'll have per frame on average and what sorts they will be.


hah yeah, i’ve never worked on a game where i care about frame timing. it’s the “beauty” of web turn-based games, lol

Joshua Suskalo14:11:21

my game engine just supports global events and the "default" way to implement event handlers is to make a multimethod reducing function that takes a game state and an event, "runs" the event on the world, and returns the new world. However it's pretty easy to build a system which will instead just index the events every frame on their targets and allow entities to listen to them with good performance.

Joshua Suskalo14:11:11

Yeah, I am in-progress on a general-purpose game engine that I'll be using for game jams and eventually more. I've put a lot of effort into the frame timing and rendering systems to try and make them extensible and ensure that simulation happens at a fixed timestep etc.

👍 1

hah yeah, coffii is sick and i can’t wait to see your engine

Joshua Suskalo14:11:39

Which actually has given me the first thing I've wanted to use reducers for

Joshua Suskalo14:11:01

My game engine is public already

Joshua Suskalo14:11:17

there's not anything particularly cool written in it yet but there's a lot of machinery in it

Joshua Suskalo14:11:36

I'd be happy to go over my event systems etc. and how I'm working to ensure super consistent and accurate frame times to make butter-smooth rendering.

Joshua Suskalo14:11:18

Which this engine is actually not using coffi (yet). I'll probably wait to port it until after I've figured out how I want to generate gl bindings in coffi.


let me see if i can explain/walk through this to see where the misunderstanding is coming from: the game state is a giant map with a nested map :players of :player-id to the player map (along with all sorts of other stuff). player 1 performs some action that damages player 2. in my scheme, i pass in state and either positional args or a map of args to the damage function, and it performs the necessary work to see if invincibility/etc applies, and if not, it performs the actual change to the state map: (update-in state [:players (-> args :target :player-id) :health] - (:amount args)) (or i’d put all this into local variables, you get the idea). and the calling function would say something like (-> state (get-input) (damage args) (print-messages) (etc)) . and at all times i’m just passing around a hashmap.


in your scheme, i pass in state and the map of args to a damage-event function, which would do something like (update state :events conj damage-event) ? and then the calling function would say something like (-> state (get-input) (damage-event args) (process-events) (print-messages))?


and process-events would pop off one of the events, and then have a multimethod that handles each type of event, and that would be the implementation of the damage function: (defmethod event-handler :damage [state args] ...)

Joshua Suskalo15:11:11

So yes, damage-event would just add in a new event to the state. There are two ways to handle processing events though. There's the way you just described (where a key feature is that process-events is performed as one of the top-level steps in your game loop and is one of a very small number of functions which can modify the whole game state), but there is also the way where your 'handling of the event' is just in your player entity update code, it looks at the list of events and does what code it wants to update itself based on the events in the queue.

Joshua Suskalo15:11:57

Usually doing it the second way will require having your events be a frame out of date though.


interesting. i feel like i’d want to see this in action cuz at the moment it doesn’t feel helpful, but i also don’t have almost any experience in this field so it can be hard to see the benefit from contrived examples


thanks for the explanation

Joshua Suskalo15:11:05

Like I said, it's basically just a way to avoid shared mutable state.

Joshua Suskalo15:11:20

And it's basically shared mutable state because every function can modify it at any time, it just happens to not use an assignment operator.

Joshua Suskalo15:11:29

In small games it's basically pointless to split things up this way, but it's the same idea as e.g. re-frame. You want to separate what to do from how to do it.

Joshua Suskalo15:11:57

For anyone making a game where framerate matters, these two articles are very important if you want your game to feel smooth:

thanks2 1
Joshua Suskalo15:11:53

The first one is a way for you to decouple your rendering and simulation so that you can have stable, deterministic simulations with rendering that goes as fast as your machine allows.

Joshua Suskalo15:11:53

In my engine I applied this by actually separating out my simulation and rendering to two different threads, and the simulation produces simulation frames to a queue and the rendering thread consumes those simulation frames and produces rendered ones.

Joshua Suskalo15:11:31

The simulation steps at a fixed timestep, and the render thread consumes the latest simulation and (if it's provided) interpolates between two game states to render the appropriate thing for a given frame.

Joshua Suskalo15:11:16

Then the second article goes into how even this isn't quite enough to have buttery smooth motion because even if you consistently hit your frame time window, you're probably actually rendering the wrong part of the frame, that is you're rendering "now" in between frames, which is variable because it doesn't always take the same amount of time to render a frame, where instead you should be rendering what the world will be at the next vblank.

Joshua Suskalo15:11:59

Which only really applies when you have vsync enabled, but is critical to getting the game to feel buttery smooth and have no stutters.

Joshua Suskalo15:11:05

This later one is more important at lower framerates, but since this is clojure any more complex game is probably going to target a lower framerate like 30-60 fps.

Joshua Suskalo18:11:58

This decoupling of render and simulation state has caused some things that I have had to build additional infrastructure around though. For example since I don't know if a particular simulation frame has been rendered yet, I have no way to tell the renderer to unload a model once I'm done with it and be sure that it won't be used in the next couple of rendered frames that haven't caught up to the current simulation. So I had to build an entire set of systems that work together to guarantee that render events are associated with a particular simulation frame and it's guaranteed that every event will be completed before their current simulation frame or any later frame is rendered, since for example there's no guarantee that each simulation step will get rendered (imagine a simulation step rate of 100 steps per second, but a monitor with a 60hz refresh rate).

Joshua Suskalo18:11:56

All this to allow me to have the simulation send an even that says "unload model x" and have it act correctly.


that sounds complicated as heck

Joshua Suskalo18:11:20

it is, but it's needed to have buttery smooth frames, which I quite like.

Joshua Suskalo18:11:51

and I also like have my simulation and rendering on different threads so that I can benefit from the fact that I have immutable game states

Joshua Suskalo19:11:27

(although I imagine eventually I'll want to make a synchronization primitive for mutable things I want to keep in my game state to ensure I don't render a version that's currently being mutated)