Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consider making GGRS client-oriented ("player-agnostic") #94

Open
caspark opened this issue Dec 15, 2024 · 2 comments
Open

Consider making GGRS client-oriented ("player-agnostic") #94

caspark opened this issue Dec 15, 2024 · 2 comments
Labels
enhancement New feature or request

Comments

@caspark
Copy link
Contributor

caspark commented Dec 15, 2024

What

Currently GGRS expects input to be submitted for a player, and there can be multiple local players for a given client.

An alternative framing worth considering is that GGRS could expect input to be submitted once for a single client, and it's the responsibility of ggrs-consuming-code to merge local player inputs into one input before submitting it to ggrs (and then split it into per-player input when advancing frames).

This would effectively make GGRS "player agnostic" - GGRS wouldn't know about players at all anymore, only clients.

Why

'Free' replication of arbitrary state

This allows replicating state that is not intrinsically tied to any one player's input - such as "what debug settings are currently enabled". Due to delta encoding, replicating debug settings is very cheap, and by using the Input type to achieve this replication, the debug settings are able to impact the game state without causing desyncs.

This can also be useful for other state like "is the game paused". E.g. if there are 3 players playing on gamepads and someone presses Esc to pause on the keyboard, which player would send the game paused input? You could arbitrarily say keyboard input just gets attached to the first player, but then you need to special case it if keyboard and gamepad players are playing at once.. in such cases I think it's simpler to have some state not tied to player inputs.

Hot join local players

If GGRS doesn't know about local players, it's possible to add local players later (even after a new controller is connected) without restarting the GGRS session; the trick is to have your input track arbitrary commands, and have a command which is "add player with local id x":

// this is what you'd submit to GGRS
struct ClientInput {
  player_inputs: Vec<LocalPlayerInput>
  commands: Vec<Command>
}

type LocalPlayerId = usize;

struct LocalPlayerInput {
  for_local_player: LocalPlayerId,
  jumping: bool,
  // etc
}

enum Command {
  AddLocalPlayer(LocalPlayerId)
}

struct ClientPlayerInput {
  // "PlayerHandle" is basically "ClientHandle" since there's only 1 ggrs player per client this way
  for_player: (ggrs::PlayerHandle, LocalPlayerId)
  jumping: bool,
  // etc
}

Then when processing an AdvanceFrame request from ggrs, you:

  • unpack the LocalPlayerInputs in MyInput and copy their data into a ClientPlayerInput.
  • remember which clients issued which commands, so every client can spawn the local player character with a (ggrs::PlayerHandle, LocalPlayerId) identifier, and know which client owns it + which input device is assigned to it.

With this technique, you can ensure all connected clients add a local player that hot-joins on client X, without having to restart the ggrs session, without using any out of band communication, and without getting a desync. (I implemented it in my game here.)

Why Not

  • You can 100% implement the "player agnostic pattern" in GGRS today just by regarding a GGRS player as a client (i.e. only ever adding one ggrs player per client - regardless of number of local players); that's what I've done in my game and it works great.
  • The approach of having to merge and split local player inputs yourself is more work for ggrs-consumers that don't care about local player hot join, especially if someone is just trying to get a game jam going.
    • The impact here could probably be reduced by providing some helper types and functions.
  • The "issue a command via Input" pattern needs custom input prediction (Custom input prediction function? #69) to avoid causing guaranteed mis-predictions & rollbacks.
  • From a networking perspective, it results in fewer but larger packets being sent over the wire - which can cause fragmentation in cases of really large inputs. (Also, the current implementation of delta encoding would potentially not work so well with packing input for multiple players into one packet - but that can be fixed.)
  • For hot-joining players specifically, you could also work around it by adding dummy players to the GGRS session that don't get used until necessary. E.g. "Only 1 player is playing now, but we support up to 4 local players, so always tell GGRS there are 4 local players" - and then set a "player has joined" flag in their input to false. But, that would result in sending small amounts of dummy data to all clients all the time, which is kinda wasteful.

Worth it?

Overall, I figure it's worth considering, because probably most games that have multiple local players would benefit from supporting local player hot join, and so far I've been pretty happy with this technique (disclaimer: I've only used ggrs for about a month!).

And even if we/you decide not to go down this path, then at least here is a written-up sketch of how to support local player hot join for anyone else interested.

@caspark caspark added the enhancement New feature or request label Dec 15, 2024
@gschup
Copy link
Owner

gschup commented Dec 15, 2024

GGRS actually was like that a while ago. I decided to separate clients/players at some point since people were asking for it. The workaround you describe kind of worked, with the caveat that all inputs of all clients had to have the same length at that point. So one would have to add useless zeros to buffer an input (or similarly hacky).

@caspark
Copy link
Contributor Author

caspark commented Dec 16, 2024

with the caveat that all inputs of all clients had to have the same length at that point. So one would have to add useless zeros to buffer an input (or similarly hacky).

Yup, that is exactly what I did: my Config::Input was [u8; 450] (so always 450 bytes), and then I relied on the run-length-encoding of inputs that ggrs does to shrink it down to a sane size before it was sent.

For the benefit of others reading this: with #82 merged now, that workaround wouldn't be necessary anymore, though without #69 there is a tiny performance impact to this approach I've sketched out (an extra rollback each time a Command is submitted by a player).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants