Author Topic: [Idea] Move the TNObject DataNode from Client-side to Server-side  (Read 301 times)

Proton

  • Newbie
  • *
  • Thank You
  • -Given: 0
  • -Receive: 0
  • Posts: 9
  • Alberta, Canada
    • View Profile
    • Time Gamers
Server, Channel & Player all have a server side DataNode, and you can set and listen to individual changes to that DataNode.
TNObject works differently though. The DataNode does technically exist server side as a parameter to an RFC, but you can't set and listen to partial changes. Any time you change 1 value, the entire DataNode is resent to the server then sent to everyone.

Why would I want to do this? To make the networking more data driven. Like how Google's Firebase Realtime Database works: https://firebase.google.com/docs/database/

For example, my FPS player TNObject could have a DataNode that contains the state of user input
{MoveH:float, MoveV:float, Jump:bool, Dash:bool, Look:Vector2}

Then calling tno.Set("Jump", true) would only need to send the data that changed. The server-side version of the DataNode would have all the latest data for when new players join. I also notice my code shrinks down in size without using RFCs ;D.

Possibly new packet types RequestSetObjectData, ResponseSetObjectData. Both with parameters [ChannelId,ObjectId,Path,Object/DataNode]
TNObject.OnDataChanged would need a path.

The goal would be network efficiency, and nicer code (IMO). But I can think of 1 possible downside with this approach.

If you want to set all 5 properties in 1 frame, it would create 5 TCP Packets instead of 1. It looks like TNTcpProtocol has an outgoing queue (mOut) but it doesn't appear to be used for batching multiple packets together.

Many games (Overwatch, Rainbow 6 Siege, Rocket League) and network libraries (Photon) have the idea of a client and server tick rate. So it would buffer up outgoing request and only send it 30 times per second for example. The server will also queue up outgoing events to specific players and send them as 1 TCP packet instead of a bunch of individual ones.

Perhaps these optimizations are no longer necessary with the massive bandwidth & low pings players have now?

Maybe there is some other downsides to making the TNObject DataNode server side that I am missing?

ArenMook

  • Administrator
  • Hero Member
  • *****
  • Thank You
  • -Given: 270
  • -Receive: 1132
  • Posts: 21,846
  • Toronto, Canada
    • View Profile
Re: [Idea] Move the TNObject DataNode from Client-side to Server-side
« Reply #1 on: January 04, 2017, 09:09:37 AM »
I've thought about it, and probably will do just that at some point. Channel/server data setting came first. TNObject data setting was something I added back in Windward and didn't want to break backwards compatibility for, which is why it was done as an RFC. It was more of a convenience feature.

Proton

  • Newbie
  • *
  • Thank You
  • -Given: 0
  • -Receive: 0
  • Posts: 9
  • Alberta, Canada
    • View Profile
    • Time Gamers
Re: [Idea] Move the TNObject DataNode from Client-side to Server-side
« Reply #2 on: January 05, 2017, 12:55:57 AM »
Cool, good to know I was on the right track.

I went ahead and made the changes, seems to work really nicely. I attached a git patch to this post.

It does break backwards compatibility in 2 places:
1. The client side RFC based DataNode is not used, so it kinda gets lost if limbo if it exists in a server save file.
2. TNChannel.SaveTo/LoadFrom:
Needed to save/load DataNode per CreatedObject. So that would be completely broken for an existing save file.
Idea: Backward & Forward compatibility if it used a DataNode instead of raw BinaryWriter?

I also noticed TNTcpProtocol.noDelay, so it looks like by default TNet uses Nagle's algorithm to optimize the sending of many small packets (at least I think it does). So that seems pretty cool!  ;D

I think the biggest concern I have is that after a TNet game is running in the wild for a while, the server-side database could become a tricky thing to manage (no standard db admin tools) and migrate forward if there are breaking changes like this one. DataNode seems to solve a lot of forward compatibility issues in theory, curious if it will work in practice. Limiting my persistence to only the RCC & Server/Channel/Player/Object DataNodes should reduce headaches down the line I think (no persistent RFCs). I imagine that an admin tool could be created in Unity to do some basic db admin.

ArenMook

  • Administrator
  • Hero Member
  • *****
  • Thank You
  • -Given: 270
  • -Receive: 1132
  • Posts: 21,846
  • Toronto, Canada
    • View Profile
Re: [Idea] Move the TNObject DataNode from Client-side to Server-side
« Reply #3 on: January 05, 2017, 10:04:41 AM »
Main reason why I kept it in an RFC is because the server doesn't parse it. This has advantages: you can put anything in the DataNode. If it goes through an explicit data setting function like SetChannel/Server data, then the server needs to know all the custom classes used within, enums etc that you use in order to be able to parse it on the server side. This is a pretty big limitation and the main reason I kept it within a single RFC. Still, nothing is preventing me from adding a custom notification method for when only a part of the DataNode changes.

Proton

  • Newbie
  • *
  • Thank You
  • -Given: 0
  • -Receive: 0
  • Posts: 9
  • Alberta, Canada
    • View Profile
    • Time Gamers
Re: [Idea] Move the TNObject DataNode from Client-side to Server-side
« Reply #4 on: January 05, 2017, 08:17:56 PM »
Good call. I did a few experiments and found a workaround.

You can do the serialization of custom types client-side and just send a byte array.

Additions to TNObject:
  1.     /// <summary>
  2.     /// Set the object-specific data. Allows for types that the server can not parse by wrapping it in a DataNode then serializing it client side.
  3.     /// </summary>
  4.  
  5.     public void SetCustomType( string path, object data ) {
  6.         DataNode node = new DataNode("", data);
  7.         Set(path, node.ToArray());
  8.     }
  9.  
  10.     /// <summary>
  11.     /// Get the object-specific data. Allows for types that the server can not parse by wrapping it in a DataNode and doing the deserialization client side.
  12.     /// </summary>
  13.  
  14.     public T GetCustomType<T>( string name ) {
  15.         return GetCustomType<T>(name, default(T));
  16.     }
  17.  
  18.     /// <summary>
  19.     /// Get the object-specific data. Allows for types that the server can not parse by wrapping it in a DataNode and doing the deserialization client side.
  20.     /// </summary>
  21.  
  22.     public T GetCustomType<T>( string name, T defVal ) {
  23.         byte[] bytes = Get<byte[]>(name);
  24.         if( bytes == null ) return defVal;
  25.  
  26.         DataNode node = DataNode.Read(bytes);
  27.         if( node == null ) return defVal;
  28.  
  29.         return node.Get<T>(defVal);
  30.     }
  31.  

An example of it in use:

https://gfycat.com/LivelySereneBrahmancow

  1. using UnityEngine;
  2. using TNet;
  3.  
  4. public class ExampleCarType : TNBehaviour {
  5.  
  6.     public enum CarType {
  7.         Tesla,
  8.         PoliceCar
  9.     }
  10.  
  11.     public CarType carType = CarType.Tesla;
  12.  
  13.     public GameObject policeLights;
  14.     public GameObject teslaLogo;
  15.  
  16.     public override void OnInit() {
  17.         tno.onDataChanged += OnDataChanged;
  18.     }
  19.  
  20.     void Update () {
  21.         if( tno.isMine == false )
  22.             return;
  23.  
  24.         if( Input.GetKeyDown(KeyCode.Alpha1) ) {
  25.             SetCarType(CarType.Tesla);
  26.         }
  27.         if( Input.GetKeyDown(KeyCode.Alpha2) ) {
  28.             SetCarType(CarType.PoliceCar);
  29.         }
  30.     }
  31.  
  32.     void SetCarType( CarType newCarType ) {
  33.         tno.SetCustomType("CarType", newCarType);
  34.     }
  35.  
  36.     void OnDataChanged( string path, DataNode node ) {
  37.         if( path == "" || path == "CarType" ) {
  38.             carType = tno.GetCustomType("CarType", CarType.Tesla);
  39.             RefreshCarType();
  40.         }
  41.     }
  42.  
  43.     void RefreshCarType() {
  44.         policeLights.gameObject.SetActive(carType == CarType.PoliceCar);
  45.         teslaLogo.gameObject.SetActive(carType == CarType.Tesla);
  46.     }
  47. }

Still, nothing is preventing me from adding a custom notification method for when only a part of the DataNode changes.
The entire DataNode would still be going over the network every time a single value changes though. Efficiency depends on the project I suppose, if TNObject.Set isn't called too often then it isn't a big problem.

The ExampleCarType script doesn't show any code advantages over RFC, I've been using UniRx & ECS for a while, this is what my code would actually look like:
  1. namespace TimeWarpers {
  2.     using UnityEngine;
  3.     using uFrame.Kernel;
  4.     using UniRx;
  5.     using System;
  6.  
  7.     public enum CarType {
  8.         Tesla,
  9.         PoliceCar
  10.     }
  11.  
  12.     public partial class CarTypeSystem {
  13.  
  14.         // Setup all the CarType behaviour. No need to think about Networking here.
  15.         // This logic happens for Local & Remote (owner and non-owner)
  16.         protected override void OnCarTypeSwitcherCreated( CarTypeSwitcher carTypeSwitcher ) {
  17.  
  18.             // Listen for the latest new value
  19.             carTypeSwitcher.CarTypeLatestUniqueObservable
  20.                 .Subscribe(carType => {
  21.                     carTypeSwitcher.TeslaLogo.SetActive(carType == CarType.Tesla);
  22.                     carTypeSwitcher.PoliceLights.SetActive(carType == CarType.PoliceCar);
  23.                 }).DisposeWith(carTypeSwitcher);
  24.         }
  25.  
  26.         // Local: Logic for the owner of the net object
  27.         protected override void OnLocalCarTypeSwitcherCreated( LocalCarTypeSwitcher group ) {
  28.  
  29.             // The Local player (object owner) can use keyboard input
  30.             RxInput.OnKeyDown(KeyCode.Alpha1)
  31.                 .Subscribe(_ => group.CarTypeSwitcher.CarType = CarType.Tesla)
  32.                 .DisposeWith(group);
  33.  
  34.             RxInput.OnKeyDown(KeyCode.Alpha2)
  35.                 .Subscribe(_ => group.CarTypeSwitcher.CarType = CarType.PoliceCar)
  36.                 .DisposeWith(group);
  37.  
  38.             // Send local changes to the server
  39.             // Only send if the value changes
  40.             // Don't send more often than every 50ms
  41.             group.CarTypeSwitcher.CarTypeLatestUniqueObservable
  42.                 .Sample( TimeSpan.FromMilliseconds(50) )
  43.                 .Subscribe(carType =>
  44.                     group.NetEntity.tno.SetCustomType("CarType", carType)
  45.                 ).DisposeWith(group);
  46.         }
  47.  
  48.         // Remote: Logic for the players that don't own the object
  49.         protected override void OnRemoteCarTypeSwitcherCreated( RemoteCarTypeSwitcher group ) {
  50.             // Listen for changes to the server variable and apply them locally
  51.             group.NetEntity.GetLatestCustomType("CarType", CarType.Tesla)
  52.                 .Subscribe(carType =>
  53.                     group.CarTypeSwitcher.CarType = carType
  54.                 ).DisposeWith(group);
  55.         }
  56.     }
  57. }
Doing data-driven reactive stuff might be another programming fad (and it looks a little strange at first), but Netflix is really embracing it (and I love the quality of their software), and I find it kind of fun :P

ArenMook

  • Administrator
  • Hero Member
  • *****
  • Thank You
  • -Given: 270
  • -Receive: 1132
  • Posts: 21,846
  • Toronto, Canada
    • View Profile
Re: [Idea] Move the TNObject DataNode from Client-side to Server-side
« Reply #5 on: January 08, 2017, 02:53:19 PM »
Sure, you can do that -- but now you're using up more GC memory with the ToArray() call. I'll think of something.

Proton

  • Newbie
  • *
  • Thank You
  • -Given: 0
  • -Receive: 0
  • Posts: 9
  • Alberta, Canada
    • View Profile
    • Time Gamers
Re: [Idea] Move the TNObject DataNode from Client-side to Server-side
« Reply #6 on: January 08, 2017, 07:55:34 PM »
I got rid of some of the garbage:
  1.     /// <summary>
  2.     /// Set the object-specific data. Allows for types that the server can not parse by serializing it client side.
  3.     /// </summary>
  4.  
  5.     public void SetCustomType( string path, object data ) {
  6.         /*
  7.         // BEFORE
  8.         // 2.4 KB Garbage total when converting an enum via Reflection and wrapping in a DataNode
  9.         Profiler.BeginSample("TNObject.SetCustomType ToArray");
  10.            
  11.         DataNode node = new DataNode("", data);     // 32 B
  12.         byte[] arr = node.ToArray();                // 1.5 KB (arr.Length = 46)
  13.         Set(path, arr);                             // 0.9 KB
  14.         Profiler.EndSample();
  15.         */
  16.  
  17.         // AFTER
  18.         // 1.4 KB Garbage total when converting an enum without Reflection without wrapping in a DataNode
  19.         Profiler.BeginSample("TNObject.SetCustomType Without DataNode");        
  20.         MemoryStream stream = new MemoryStream();       //  40 B
  21.         BinaryWriter writer = new BinaryWriter(stream); //  56 B
  22.         writer.WriteObject( data );                     // 296 B (all garbage from BinaryWriter.Write(byte))
  23.         byte[] bytes = stream.ToArray();                //  42 B (all garbage from MemoryStream.ToArray)(bytes.Length = 2)
  24.         stream.Close();
  25.         Set(path, bytes);                               // 0.9 KB (all garbage from TcpProtocol.SendTcpPacket -> Socket.BeginSend)
  26.         Profiler.EndSample();
  27.     }
  28.  
  29.     /// <summary>
  30.     /// Get the object-specific data. Allows for types that the server can not parse by doing the deserialization client side.
  31.     /// </summary>
  32.  
  33.     public T GetCustomType<T>( string path ) {
  34.         return GetCustomType<T>(path, default(T));
  35.     }
  36.  
  37.     /// <summary>
  38.     /// Get the object-specific data. Allows for types that the server can not parse by doing the deserialization client side.
  39.     /// </summary>
  40.  
  41.     public T GetCustomType<T>( string path, T defVal ) {
  42.         byte[] bytes = Get<byte[]>(path);
  43.         if( bytes == null || bytes.Length==0 ) return defVal;
  44.  
  45.         MemoryStream stream = new MemoryStream(bytes);
  46.         BinaryReader reader = new BinaryReader(stream);
  47.         T obj = reader.ReadObject<T>();
  48.         reader.Close();
  49.         return obj;
  50.     }

Apparently UNET's NetworkWriter can do zero-garbage conversion of simple values. I didn't try it though.

I also got it to serialize enums more efficiently. First define the enum to only use a byte:
  1.     public enum CarType : byte {
  2.         Tesla,
  3.         PoliceCar
  4.     }

Then modify TNSerializer:
  1.     static int GetPrefix (Type type)
  2.     {
  3.         if( type.IsEnum ) {
  4.             type = Enum.GetUnderlyingType(type);
  5.         }

This gets the bytes sent over the network for an enum from 46 to 2 since it doesn't need to use reflection.

ArenMook

  • Administrator
  • Hero Member
  • *****
  • Thank You
  • -Given: 270
  • -Receive: 1132
  • Posts: 21,846
  • Toronto, Canada
    • View Profile
Re: [Idea] Move the TNObject DataNode from Client-side to Server-side
« Reply #7 on: January 13, 2017, 10:47:23 PM »
Teslas can be police cars too, you know. http://bgr.com/2016/10/17/tesla-model-s-police-cars-los-angeles/

Two issues with enum serialization changes. First, it will break previously saved serialization of existing enums, so is not backwards compatible. Second, saving the underlying type makes it effectively humanly-unreadable after parsing it back. Try saving it to binary, then reading that binary and printing it into debug.
  1. using UnityEngine;
  2. using TNet;
  3. using System.IO;
  4.  
  5. public class Test2 : MonoBehaviour
  6. {
  7.         public enum Testing
  8.         {
  9.                 First,
  10.                 Second,
  11.         }
  12.  
  13.         public enum Testing2 : byte
  14.         {
  15.                 First,
  16.                 Second,
  17.         }
  18.  
  19.         void Start ()
  20.         {
  21.                 DataNode dn = new DataNode();
  22.                 dn.AddChild("A", Testing.First);
  23.                 dn.AddChild("B", Testing2.Second);
  24.                 Debug.Log(dn);
  25.  
  26.                 var buff = Buffer.Create();
  27.                 dn.Write(buff.BeginWriting());
  28.  
  29.                 var reader = buff.BeginReading();
  30.                 dn = reader.ReadObject<DataNode>();
  31.                 Debug.Log(dn);
  32.         }
  33. }
  34.  
Run that. The first debug output is readable, A = "First" and B = "Second". The second debug output is not readable: A = 0 and B = 1.

ArenMook

  • Administrator
  • Hero Member
  • *****
  • Thank You
  • -Given: 270
  • -Receive: 1132
  • Posts: 21,846
  • Toronto, Canada
    • View Profile
Re: [Idea] Move the TNObject DataNode from Client-side to Server-side
« Reply #8 on: January 13, 2017, 10:50:36 PM »
P.S. note that if you're really conscious about sending enums, send them as bytes instead by casting them.
  1. dn.AddChild("A", (byte)Testing.First);
Same effect as your modification, without actual TNet modification.