ivanperez-keera / Yampa

Functional Reactive Programming domain-specific language for efficient hybrid systems
442 stars 50 forks source link

Is it possible to create a state snapshot? #166

Closed mastarija closed 2 years ago

mastarija commented 4 years ago

I'm making a multiplayer game and I want to be able to create a state snapshot so that I can go back in time and reapply input deltas to handle lag, client side prediction etc...

Is it possible to make a copy / snapshot of ReactHandle and then later rewrite the original ReactHandle with an older snapshot?

I've heard there was a function in previous versions of Yampa called age or something which would allow for such shenanigans, but I can't find anything like it in the current version.

walseb commented 4 years ago

While I have never played around with time traversal I believe this is relevant: https://www.cs.nott.ac.uk/~psxip1/papers/2017-HaskellSymposium-Perez-BackToTheFuture-TimeTravelInFRP-latest.pdf And if you need any inspiration for the project you describe (although without any lag compensation) there is this project that's also doing multiplayer in yampa: https://github.com/Mattiemus/LaneWars

mastarija commented 4 years ago

@walseb I have seen your project, and have read the paper before ;D but this is not really what I want as I need to integrate delayed input deltas (and basically patch the state).

From what I understand, the approach described in the paper is great if I need to roll back the state and continue receiving the new input, but that's not the case if I need to, for example, inject a delayed input between two previous inputs and recalculate the state with that new input taken into account.

walseb commented 4 years ago

To be clear it's not my project but good luck! Actually there is one guy who I believe spoke about doing lag compensation and such using pure AFRP on the FP discord a while back, so he might want to chip in @RiugaBachi. Although I believe he was using bearriver and not yampa

mastarija commented 4 years ago

@walseb ah.. indeed, I got confused because I've somehow associated your thumbnail with FRP games :D, I would love to hear from @RiugaBachi though.

RiugaBachi commented 4 years ago

Thanks for the mention. In fact, I've given up on bearriver a long time ago due to performance. My recommendation now is Yampa for simulations with trivial / what I deem "flat" state. Anything more complex and it's slim pickings in the current FRP scene. Currently working on my own implementation tegami which aims to support subnetting as a first class construct along with optimizations in the base encoding of the arrow.

It seems what you want to achieve is clientside resync. First, let's look at the definition of ReactHandle:

-- reactimate's state, maintained across samples:
data ReactState a b = ReactState {
    rsActuate :: ReactHandle a b -> Bool -> b -> IO Bool,
    rsSF :: SF' a b,
    rsA :: a,
    rsB :: b
  }

-- | A reference to reactimate's state, maintained across samples.
newtype ReactHandle a b = ReactHandle
  { reactHandle :: IORef (ReactState a b) }

-- | Process a single input sample.
react :: ReactHandle a b
      -> (DTime,Maybe a)
      -> IO Bool
react rh (dt,ma') =
  do rs@(ReactState {rsActuate = actuate, rsSF = sf, rsA = a, rsB = _b }) 
         <- readIORef (reactHandle rh)
     let a' = fromMaybe a ma'
         (sf',b') = (sfTF' sf) dt a'
     writeIORef (reactHandle rh) (rs {rsSF = sf',rsA = a',rsB = b'})
     done <- actuate rh True b'
     return done

Point being rsActuate is constant, set once in reactInit and that's it. Therefore when you 'snapshot' a ReactHandle what you are effectively storing is (SF' a b, a, b) at some point in time. I'm making this simplification as it is more applicable to other AFRP implementations and can be applied in a pure simulation scenario.

Yampa does not export the fields of ReactHandle, therefore the end user has no way to clone the internal state of the IORef. Merely storing some ReactHandle would of course be a classic case of copying the pointer and not the pointee. I am sure you've noticed this already and are therefore asking this question to see if said interface exists; it does not. At this point, you could forcibly build your own reactimate loop by importing FRP.Yampa.InternalCore and making use of sfTF', keeping track of said tri-tuple of state along with their DTimes, and implementing what you want, but it's a bit hacky and unfortunate that the library tries to discourage this practice as much as possible. There is also evalFuture from the core library however it is less flexible for optimization and is equally as hacky. I am not sure what your exact requirements are but there may be alternative designs to achieve clientside resync if you would like to discuss this further.

This is one of several problems I have with Yampa's design. There are step-based functions that exist but are hidden away from the end user, on the whole basis of 'breaking the FRP abstraction'. I don't disagree with this fact, however I disagree with the conclusion that the end user needs to be protected from shooting themselves in the foot because of it. Personally, I would rather re-envision the library as an FRP framework with all the facilities needed for an end-user to build their own abstraction-abiding reactimation system, rather than trying to fit every conceivable use case into the construct of an IO-based reactimate / reactInit/react. Optimization of reactimation loops should be something the end user is expected to learn about, and resources should readily be made available from those of us who have learned it the hard way. In this sense, the end-user is given more flexibility to tailor reactimation to their needs and choose their safety guarantees at the expense of performance or less favorable memory semantics.

But enough with the rant; this is just my opinion and I am not persuading Ivan to change his vision of Yampa. Moreso a statement of my philosophy going into development of tegami.

ivanperez-keera commented 4 years ago

@RiugaBachi I wouldn't mind having a chat about this, possibly via video call. I want to hear your ideas on using Yampa in more depth.

I also want to know about the issues you had with bearriver. My benchmarks with games with -O2 (IIRC) on GHC 8.8 showed that BR was orders of magnitude faster than Yampa. Somehow, GHC was able to optimize monads away and make things really, really fast. So, if this was an obstacle for you, I'd have to fix it. But this is compiler and flag-dependent.

mastarija commented 4 years ago

@RiugaBachi Yes you are pretty much on point. I do want to implement a client / server reconciliation and possibly server side lag compensation (which I guess would require looking at things from each client's perspective).

I was hoping there's some kind of magical switch which can "fork" the state and allow me to switch back into it at a later point in time.

The game I'm working on is just a simple top down shooter where players shoot balls at each other and there are some static obstacles to make the terrain more interesting, provide cover and make bullets bounce off in various directions.

I have already considered alternative approach to structuring my game. Because my state is relatively simple I can just collect the final state, save it and remember all the input deltas from that point onwards.

When I need to resync I can just pump that checkpoint and overwrite the SF state with data from that last checkpoint and repeat the input deltas (with the new server inputs included). Problem here is that I need to be very clever about my data structures and how I use primitives like integral. I'll most likely have to rely on iterFrom in order to integrate (pun intended) the resync in my game.

If you maybe have some further advice on this topic I'd really appreciate it :)

RiugaBachi commented 4 years ago

One of the prime benefits of FRP is the ability to hide what would otherwise be messy state-keeping logic for summation (including integration), accumulation, etc. In order to fundamentally achieve this, some sort of hidden state needs to be stored within the definition of said SFs, except no ideal way exists to override said internal state from within the network (i.e. without having consistency which now hinges on the definition and application of nonstandard SFs). Thus, you are correct in your initial premise that the only way of maintaining consistent state as guaranteed by the FRP abstraction is to snapshot the whole SF.

As far as I'm interpreting it, your newly proposed alternative is to try and change the state of the SF from within the network. Speaking from experience, this sounds good in theory, until you actually try to implement it. The state that you are "pumping" in would have to be an arrow input supplied by the sensor, so already we run into the limitation that this state can only be fed around internally as arrow input. The problem is that SFs take a single input, so you'll need to change the logic behind everything to expect something like data In a b = Override a | Feed b to differentiate between, say, overriding the internal integration counter or feeding it new input. I'm going to interject for a moment and say that this approach definitely does not scale and blows up the complexity of the network significantly; it's definitely not something I would consider for the complex MMO state that I work with. More importantly however, the matching logic behind In will break laziness, so you will not be able to use rec notation. This could be acceptable for your use case, I wouldn't know. You can still loop via loopPre / loopIntegral however it can be significantly more verbose. I found this limitation quite annoying after a while and accepted the fact that I need rec for ergonomics.

Generally if I ever catch myself thinking along the lines of "I need to be very clever about ... how I use primitives like integral", as you mentioned, what I'm about to do most likely isn't a good idea. The fact of the matter is that these primitives are designed to "just work", and if it isn't working for us then we're essentially going against the grain of the whole AFRP abstraction, which generally leads to Bad Things™. This is why my attention has turned to solving limitations at the level of the definition of SF itself.

mastarija commented 4 years ago

Thanks for confirming my suspicions @RiugaBachi. Luckily, Yampa is open source so I can just fork it and expose reactHandle field and ReactState type constructor to handle my "dirty" needs.

Honestly, I don't see the need to hide this two as I think making a state snapshot is a legitimate use case. I guess some continuation based interface would be more desirable but this seems like a quick fix at the moment.

@ivanperez-keera would you consider accepting a pull request with reactHandle and ReactState type constructor exposed?

ivanperez-keera commented 4 years ago

There's a lot going on in this question. I'll try to select some points I can answer during my break:

I've heard there was a function in previous versions of Yampa called age

I cannot find such a function even in older versions of Yampa. Where did you find that?

The problem is that SFs take a single input, so you'll need to change the logic behind everything to expect something like data In a b = Override a | Feed b to differentiate between, say, overriding the internal integration counter or feeding it new input.

I'm really not convinced that what @mastarija wants can't be done through a combination of switches, sample windows and, perhaps, case. I also do not understand why there would be a scalability problem in what you propose.

I sort of get it, but can you @mastarija try to describe what you want precisely? (and I mean specifically the temporal behaviour at the time of re-setting). For example, if you first get the inputs (0.1, i1), (0.2, i2), ..., (1.0, i10), and what to reset, insert (0.05, i05), and paste the rest, what do you really re-apply? Is the delta associated with i1 0.1 or 0.05 (that is, 0.1 - 0.05).

I disagree with the conclusion that the end user needs to be protected from shooting themselves in the foot because of it.

This is indeed interesting, but perhaps the place is a separate in-person call or discussion about how to structure the different libraries as a whole to provide a joint ecosystem that suits us all. These asynchronous discussions are, at least for me, much harder to follow than a call.

There's two elements at play: the need for an FRP-abiding library (and I mean FRP not F;RP), and the need to adaptation. Yampa does break the FRP abstraction in some places. I do not like it, and it's one of the biggest complaints that e.g., Conal Elliott had when we discussed Yampa in the past. Some of those non-FRP constructs perhaps do not need to exist at the level of Yampa, but could be exposed by underlying libraries or optional flags (when cabal supports dependencies with optional flags).

In order to make changes to Yampa, and accept more PRs, one of the most annoying additions I've been pursuing for years has been a test suite that checks for temporal continuity (the machinery exists, but writing all the properties is very time consuming), and proper benchmarks (which I've mentioned before). I've searched for contributors, students, and even paid people to work on this, but it is just a really, really big task. Some help doing that heavy lifting would be appreciated, and I think it would benefit all these projects and facilitate extending them without fear of breaking things.

As for exposing things, in the sort term, I'd recommend one thing. I've had the problem in the past of adding features to Yampa, and found myself having to "fork" it. There is a branch develop-games that adds features almost "at will", and I'm happy to experiment there. I'd recommend you try to rebase that (it's slightly outdated but Yampa has changed very little; PR's for that branch would be appreciated) and, if nothing lets you do what you want, then just add what you need there. We can later determine what in the design needs to change for such a feature to exist.

By the way, bear in mind that this feature is potentially leaky, and we generally want to avoid unbounded memory leaks.

mastarija commented 4 years ago

I cannot find such a function even in older versions of Yampa. Where did you find that?

I was actually talking with Prof. Nilsson and he mentioned there was supposedly such function in one implementation of Yampa.

I sort of get it, but can you @mastarija try to describe what you want precisely?

Ok, so the clients and the server run at different tick rates (server is slower, client is faster), this is so that I can save processing power and accept "late" input from other clients.

Here are inputs from different clients sent to the server :

While this ticks were travelling to the server, client was pretending as if they were accepted to make it seem like there's no lag. ClientA doesn't know inputs from ClientB and vice versa, so once server sends back the confirmation of processed inputs I need to roll back to the last confirmed state and apply combined input e.g.:

making this the new confirmed state, plus any additional input that happened on the client side after this new confirmed input.

Basically I want to explore how the network would behave if I try a different set of inputs after a certain amount of time.

Thanks for taking some time to answer my questions. @ivanperez-keera

By the way, how much knowledge does one need to write tests for temporal continuity? Right now I have my job and my thesis to write, but I should have more free time in September.

ivanperez-keera commented 2 years ago

Due to lack of activity, I'm closing this issue. If the question persists and you want to re-open, please do so.