RiptideNetworking / Riptide

Lightweight C# networking solution for multiplayer games.
https://riptide.tomweiland.net
MIT License
1.14k stars 144 forks source link

[Question] Example usage of Notify send mode? #131

Closed Charanor closed 10 months ago

Charanor commented 10 months ago

Hello! I've been using Riptide for a while now using the old send modes and I was wondering if it would be possible to have an example usage of the Notify send mode? It seems really useful but I can't quite wrap my head around the implementation. Even pseudocode would be helpful. Is my assumption correct that it would be used something like this (pseudocode):

last_id = -1

fn Update:
    if position_changed:
        message = Create(Notify)
        message.Add(position)
        last_id  = Send(message)

fn NotifyDelivered(id):
    if id == last_id:
        last_id = -1

fn NotifyLost(id):
    if id == last_id:
        // Re-send position message
        message = Create(Notify)
        message.Add(position)
        last_id = Send(message)

Or maybe I'm supposed to cache the message like this?

last_id = -1
last_message = null

fn Update:
    if position_changed:
        last_message = Create(Notify)
        last_message.Add(position)
        last_id = Send(last_message, release: false)

fn NotifyDelivered(id):
    if id == last_id:
        last_id = -1
        last_message.Release()
        last_message = null

fn NotifyLost(id):
    if id == last_id:
        // Re-send position message
        last_id = Send(last_message, release: false)

Also, since the message does not contain a message id (out of curiosity, why is that? 😃) is it expected that we add our own way of identifying a message, like message.AddUshort((ushort)MessageId.Position) or is there another way of identifying the type of a message?

In addition I have a question about this part of the documentation:

both ends of the connection must send notify messages at a similar rate in order for it to work properly!

Does this mean that a peer, if it doesn't have anything valuable to send, should send some sort of "Noop" Notify message just to allow the acks to function properly?

Charanor commented 10 months ago

P.S. the docs seem to be using the wrong send mode in the example (Reliable instead of Notify): image

Charanor commented 10 months ago

Also I think it would be nice to clarify in the docs exactly how "ordering" is done. I assume it's done automatically behind the scenes but it's unclear if sending the same message (as in; exact same, like in my 2nd example above) will be treated as the same "instance" or if it will be a newer instance according to the internal logic.

In addition I feel like using the Connection class to listen to Notify events is a bit unintuitive since the other modes are done through the Server or Client classes. Not a big deal though 😃

tom-weiland commented 10 months ago

Caching the message typically doesn't make sense because you're presumably packing it as full as possible with a bunch of different data. By the time the packet is confirmed as lost there's a good chance that at least some of the data in the message had already changed again and was already sent in a newer message, so resending the lost message as is would mean resending data that doesn't need to be resent.

Your first pseudo code example is kind of what you want, although instead of just storing the last_id you'd probably want to have a dictionary of sequence IDs that map to "records" of when data changed and when it was last sent. I haven't bothered with any examples/pseudo code because this stuff can get quite complicated very quickly.

since the message does not contain a message id (out of curiosity, why is that? 😃)

Because it's not necessary. If you're filling messages with all sorts of different data, the concept of message "types" becomes kind of useless—instead of one message type for each type of update, you probably only need a small handful of message types, one of which will contain all your game state related data for each tick. In that case, using a ushort just to store a value in the (for example) 0-5 range is overkill, so it's better to leave that up to developers.

Message "types" are literally just a ushort which determines what the rest of the data in the message looks like. If you want that, implementing it yourself is pretty trivial (as you also mentioned).

Does this mean that a peer, if it doesn't have anything valuable to send, should send some sort of "Noop" Notify message just to allow the acks to function properly?

In a typical fast-paced game (which would probably be the most common use case for Notify), clients send input every tick and servers send a state update every tick, so I'm not really sure in what scenario either end "doesn't have anything valuable to send." But yes, if you have nothing to send, just send an empty message so the acks are transmitted—even then it won't be less bandwidth-efficient than using Reliable mode.

the docs seem to be using the wrong send mode in the example (Reliable instead of Notify):

Hmm good catch!

I think it would be nice to clarify in the docs exactly how "ordering" is done

It is explained: "Notify mode guarantees order by simply having the receiver discard any out of order messages it receives. No packet buffering or reordering takes place on the receiving end."

it's unclear if sending the same message (as in; exact same, like in my 2nd example above) will be treated as the same "instance" or if it will be a newer instance according to the internal logic.

Each time you call Send a fresh sequence ID is used. The sequence ID is the only thing used to determine whether a message is a duplicate or out of order, message contents are not checked in any way.

In addition I feel like using the Connection class to listen to Notify events is a bit unintuitive since the other modes are done through the Server or Client classes.

Because of how Notify works, the primary use case involves tracking data that has changed, and tracking when those changes were last transmitted which is inherently a per-connection thing. Giving each connection its own event is more natural and saves you from doing an extra dictionary lookup to determine which connection's "records" you need to be operating on. The fact that it doesn't match how the other send modes work bothered me too, but it made more send for how Notify is used.

Charanor commented 10 months ago

Thank you for your answer! You cleared up a lot of confusion for me! This was especially eye-opening:

instead of one message type for each type of update, you probably only need a small handful of message types, one of which will contain all your game state related data for each tick

So just to clarify, would you suggest doing something like this?

current_tick = 0
last_changed = Dict()
message_sent_tick = Dict()

fn Update:
    current_tick += 1
    gamestate_delta = GetStateDelta()

    message = Message.Create(Notify)
    for (state, value) in gamestate_delta:
        AddGamestateToMessage(message, state, value)
        last_changed[state] = current_tick

    id = Send(message)
    message_sent_tick[id] = current_tick

fn NotifyLost(id):
    tick = message_record[id]
    for state in GetStateUpdatesForTick(tick):
        MarkNeedsResend(state) // Adds this state to return value of GetStateDelta()

Sent you a tip for creating such a good library for everyone to use 🙂

Charanor commented 10 months ago

Another question (sorry for so many!) is it a bad idea to increase MaxPayloadSize of a message? When I first synchronize my gamestate there can be more than 1231 bytes to send. My naïve solution is to increase the max payload size to a size where I know I can fit all of my game state, but I have a hunch that it's set to 1231 for a good reason (maximum transmission unit?) so maybe it's better to manually split the data into several messages?

tom-weiland commented 10 months ago

Thanks for the tip! I really appreciate that 🙏

would you suggest doing something like this?

That depends on if you're trying to build a delta snapshot (DS) or eventual consistency (EC) system. DS works best for games where you can consistently fit the entire world state's changes into a single packet, because it works by compressing the state against the latest state that you know the other end has received, and you don't want to be tracking different "last synced" times for different parts of the state.

EC cares less about each individual change and just ensures that "if nothing else changes from this point on, all peers will eventually be consistent." Instead of sending deltas relative to some known global game state, if a property changes, the whole updated value is sent (if I get shot and my health goes from 10 to 7, 7 would be sent over the network, while DS would send -3). Since EC doesn't have to care about previously synchronized values, it's better suited to games with large state where each player only cares about a small portion of that state at any given time (interest management).

So if you go with EC, you don't want the state "delta" every tick, you want a list of all the objects/properties that changed.

Also (again, with EC), just because a message was lost does not mean that all its data needs to be marked/queued for resending. A value may have changed after the lost message was sent, in which case it has probably already been sent again, and unless that send was also lost there's no need to send it yet another time.

For example, if I as a player get shot twice in quick succession, my health will change twice. If the message containing that first state update is lost, there's no need to send the health update again unless that second shot hasn't happened yet. But if it has, then another health update has already been sent, so there is no point in resending the first one (which was lost).

I have a hunch that it's set to 1231 for a good reason (maximum transmission unit?)

Correct! If you send messages that are larger than the default MaxPayloadSize, they are more likely to be fragmented, and if any one of those fragments goes missing the entire packet is discarded, thereby effectively increasing your connection's loss rate. In most cases, if you have to increase the MaxPayloadSize there's probably a better way of doing what you're doing.

Charanor commented 10 months ago

I ended up doing something akin to this:

class Entity {
    bool positionChanged;
    ushort positionLastTransmittedFrameId;
    Vector2 position;

    // When position is changed, set positionChanged = true
}

frame_map = Dict()

fn Update:
    message = Message.Create(Notify)
    for entity in AllEntities():
        if entity.positionChanged:
            message.Add(entity.position)
            entity.positionLastTransmittedFrameId = GetCurrentFrame()
            entity.positionChanged = false
    sequenceId = SendMessage(message)
    frame_map[sequenceId] = GetCurrentFrame()

fn NotifyLost(sequenceId):
    frame = frame_map[sequenceId]

    message = Message.Create(Notify)
    for entity in AllEntities():
        if entity.positionLastTransmittedFrameId == frame:
            message.Add(entity.position)
            entity.positionLastTransmittedFrameId = GetCurrentFrame()
    sequenceId = SendMessage(message)
    frame_map[sequenceId] = GetCurrentFrame()

Basically I keep track of the most recent frame where I transmitted a value (position in this example). Then if a message is lost I re-transmit only the values that last changed on the frame of that message (if lastTransmittedFrameId is not the same frame it means newer data has already been sent, so no need to re-send like you said).

In my actual implementation using an ECS I built myself combined with a source generator to auto-generate all of the networking code needed:

public struct Transform {
    // Will generate a public property and a bunch of methods to serialize the Transform component into a
    // compact network stream and deserialize it.
    [Networked] private Vector2 position;
}

Idk if that's how it's "meant" to be done but it works! Thanks so much for your support :)