ShiVa Networking part 2: Sessions, Events, Broadcasting – ShiVa Engine

ShiVa Networking part 2: Sessions, Events, Broadcasting

Last week, we introduced the fundamentals of working with ShiVa's networking and wrote a simple program that connected to a remote server session. This week, we are going to build a simple chat application that allows 2 or more clients to exchange text messages. Along the way, you will learn more about sessions, multiplayer events, the user API, and common pitfalls for communicating between remote AIs that you are probably not aware of if you only ever developed single player games.

Chat application blueprint

Before building any application, you should sketch out the steps involved and the rudimentary control flow. For our simple chat, we are going to do the following:
1. connect to server (last week) and select a session
2. load a (shared) scene, since broadcasting commands are bound in the scene API
3. invoke a chat HUD instance
4. populate the userlist by scanning the scene for connected users
5. use broadcasting to transmit a message to all scene users
overview editor

Player ID and custom info

Just like with the server info, we need a central storage container for all player info. We can edit last week's NETWORK_connect.onInit to reflect that:

  1. --------------------------------------------------------------------------------
  2. function NETWORK_connect.onInit ( )
  3. --------------------------------------------------------------------------------
  4. -- fill server info table with some default data
  5. local serverinfo = this._htServer ( )
  6. hashtable.add ( serverinfo, "ip", "127.0.0.1" )
  7. hashtable.add ( serverinfo, "port", "5354" )
  8. hashtable.add ( serverinfo, "session", "Default" )
  9. hashtable.add ( serverinfo, "isConnected", false )
  10. hashtable.add ( serverinfo, "isPending", false )
  11. hashtable.add ( serverinfo, "hasScannedForSessions", false )
  12. -- same for player info
  13. local playerinfo = this._htPlayer ( )
  14. hashtable.add ( playerinfo, "id", 0 )
  15. hashtable.add ( playerinfo, "name", "ShiVaUser" ..math.floor ( math.random ( 10, 100 ) ) )
  16. --------------------------------------------------------------------------------
  17. end
  18. --------------------------------------------------------------------------------

A user ID other than 0 will be assigned to you by ShiVa automatically as soon as you connect to a server, while the name is something users will want to customize on their own, which must therefor be transmitted to remote users manually.
Changing your player ID from 0 to something else is one of the first things the server will do to a client after a successful connection. To store your new ID, you have to capture the event and modify your hashtable value:

  1. --------------------------------------------------------------------------------
  2. function NETWORK_connect.onUserIDChange ( nOldUserID, nNewUserID )
  3. --------------------------------------------------------------------------------
  4. log.message ( "EVENT: " ..nOldUserID .." changed their ID to " ..nNewUserID )
  5. -- overwrite player ID in info hashtable for easy lookup
  6. local playerinfo = this._htPlayer ( )
  7. local playerID = hashtable.get ( playerinfo, "id" )
  8. if playerID == nOldUserID then
  9. hashtable.set ( playerinfo, "id", nNewUserID )
  10. log.message ( "My new Player ID is: " ..nNewUserID )
  11. end
  12. --------------------------------------------------------------------------------
  13. end
  14. --------------------------------------------------------------------------------

Multi-session servers

One server can multiple sessions at once. On the other hand, clients in one session cannot interact with the other sessions, and only one session ("current session") can be active on a client at any time. It helps to think of sessions as chatrooms, where you can only send messages to the people in the same room you are connected to.
Last week's connection code simply assumed that there was only one session running on the server - but this is not always the case. Before you connect to a server session, you can scan for what is already running. Staying with the chatroom metaphor, you can use this to show users a selection of available chatrooms. The modified connection loop from last week's sample would look something like this:

  1. --------------------------------------------------------------------------------
  2. function NETWORK_connect.connection_onLoop ( )
  3. --------------------------------------------------------------------------------
  4. -- server connection stuff comes before here
  5. hashtable.set ( serverinfo, "isPending", false )
  6. -- if multiple sessions are running on the server, tell us
  7. if hashtable.get ( serverinfo, "hasScannedForSessions" ) == false then
  8. local nSessionCount = server.getSessionCount ( hCurrentServer )
  9. if nSessionCount > 1 then
  10. -- run through all sessions and check if the name exists
  11. local sname = ""
  12. for i=0, nSessionCount-1 do
  13. sname = server.getSessionNameAt ( hCurrentServer, i )
  14. log.message ( "Server is running a session: " ..sname )
  15. end
  16. end
  17. hashtable.set ( serverinfo, "hasScannedForSessions", true )
  18. end
  19. -- etc.
  20.  

Since we are operating in an onLoop state, we have to introduce another control variable "hasScannedForSessions" into our serverinfo hashtable, otherwise the session info would be queried every time the loop is executed: Since the loop runs for at least 3 frames (server pending/connect, session connect/pending, session connected) you would dump the same info into the log at least 3 times.

  1. server.setCurrentSession ( hCurrentServer, "myCoolSessionName" )

Executing server.setCurrentSession will always create a new session of the specified name on the server if no such session is already running. If you want to prohibit that behaviour, you either need to hardcode session names into your program, or only allow users to choose from the scanned list you obtained through server.getSessionNameAt in the previous example.

Session events

Sessions have two events: onUserEnterSession and onUserLeaveSession. When a new client connects to a server, the "Enter" event is triggered for all clients, but in different ways: Already connected clients will see only a single event with the new client transmitting its ID, while the new client will get an event for every client already in the session. Keep this in mind, since the onUserEnter/LeaveScene events work differently.

Loading a scene

After you have successfully connected to a session, you need to load a scene, since a lot of broadcast and remote user commands are bound to the scene API. If you do not intend to load any 3D content, like we do in our chat, we might as well load an empty dummy scene - but load one we must.
Loading a multiplayer scene is essentially the same as loading a single player scene. It only becomes more complicated once you have multiple scenes, as you need to communicate the different scene names with all users in the session, so the same scenes are loaded for all users.

Scene events

Scenes have two events: onUserEnterScene and onUserLeaveScene. When a new client connects to a scene, the "Enter" event is triggered only for the clients having already loaded the scene, but not for the new client. In order to see who has loaded the scene before you entered, you must scan the users in the scene:

  1. -- load dummy scene
  2. application.setCurrentUserScene ( "COMMON_dummy" )
  3. local hScene = application.getCurrentUserScene ( )
  4. if hScene == nil then
  5. log.warning ( "NETWORK_chat.chat_onEnter: could not verify scene handle" )
  6. else
  7. -- get all users in scene for username table
  8. local nUsers = scene.getUserCount ( hScene )
  9. local rUser = nil
  10. for i=0, nUsers-1 do
  11. rUser = scene.getUserAt ( hScene, i )
  12. -- don't worry about the following event, we will explain it later
  13. user.sendEvent ( rUser, "NETWORK_connect", "onQueryPlayerInfo", "name", user.getID ( this.getUser ( ) ), "NETWORK_chat", "onReceivePlayername" )
  14. end
  15. end

This listing will include your own player ID. For our chat, this is wanted behaviour, since we want to list our own name in the userlist, but you might not want to in your own game, so keep that in mind.

A simple chat UI

For our UI, we will need a list component for the chat lines, another list component for the connected users, an edit box for the text put, and a button which submits the text.
ui pic
The button itself connects to a custom event in the ChatAI, which takes the input, clears it from the field, and broadcasts it to all users in the scene:

  1. --------------------------------------------------------------------------------
  2. function NETWORK_chat.onSendButtonPressed ( )
  3. --------------------------------------------------------------------------------
  4. local hUser = this.getUser ( )
  5. -- check if we are allowed to send
  6. if this._bChatting ( ) == false then return end
  7. -- boring: get UI component, getLabelText, setLabelText="", return text
  8. local m = this.getInputLineText ( )
  9. if m == nil then return end -- ignore if empty
  10. -- broadcast section
  11. local hScene = application.getCurrentUserScene ( )
  12. -- call a custom event which returns the name of your own player
  13. local myname = user.sendEventImmediate ( hUser, "NETWORK_connect", "onQueryPlayerInfo", "name", nil, nil, nil )
  14. -- broadcast the name to a custom AI Event (which all users have) which adds the player name to a table
  15. scene.sendEventToAllUsers ( hScene, "NETWORK_chat", "onReceiveChatmessage", myname ..": " ..m )
  16. --------------------------------------------------------------------------------
  17. end
  18. --------------------------------------------------------------------------------

Remote custom event chain

Generally speaking, you can target custom multiplayer events to users in two ways, either as boradcast (scene.sendEventToAllUsers) or to individual users, the latter of which requires you to send your userID, AIModel name and Event olong with the original request, in case you need a return value. In our chat for instance, we need to transmit the username of every user to another client. We know the value is stored in a hashtable. We will provide the information through a handler in the connect AI, which also stores the playerinfo hashtable:

  1. --------------------------------------------------------------------------------
  2. function NETWORK_connect.onQueryPlayerInfo ( sKey )
  3. --------------------------------------------------------------------------------
  4. local ht = this._htPlayer ( )
  5. local storedKey = hashtable.get ( ht, sKey )
  6. return storedKey
  7. --------------------------------------------------------------------------------
  8. end
  9. --------------------------------------------------------------------------------

If this were single player, we could make use of the return value in user.sendEventImmediate, where rUser is the handle to the remote user:

  1. local name = user.sendEventImmediate ( rUser, "NETWORK_connect", "onQueryPlayerInfo", "name" )

Unfortunately, this does not work since sendEventImmediate cannot be used on remote users. The approach above does not work. Instead, we have to use sendEvent (without Immediate) and modify both our handler and request to include the userID, the target AIModel of that user, and the target event, so we can capture the result. And yes, you need to send the user ID instead of the user handle. You can easily switch between ID and handle using user.getID and application.getUser though.
The request:

  1. user.sendEvent ( hUser, "NETWORK_connect", "onQueryPlayerInfo", "name", user.getID ( this.getUser ( ) ), "NETWORK_chat", "onReceivePlayername" )

The new handler looks like this:

  1. --------------------------------------------------------------------------------
  2. function NETWORK_connect.onQueryPlayerInfo ( sKey, nAnswerToUserID, sAnswerToAI, sAnswerToEvent )
  3. --------------------------------------------------------------------------------
  4. local ht = this._htPlayer ( )
  5. local storedKey = hashtable.get ( ht, sKey )
  6. if nAnswerToUserID ~= nil then
  7. local hAnswerToUser = application.getUser ( nAnswerToUserID )
  8. if hAnswerToUser == nil then log.warning ( "connect.onQueryPlayerInfo: could not resolve user handle!" ) return storedKey end
  9. user.sendEvent ( hAnswerToUser, sAnswerToAI, sAnswerToEvent, user.getID ( this.getUser ( ) ), storedKey )
  10. end
  11. return storedKey
  12. --------------------------------------------------------------------------------
  13. end
  14. --------------------------------------------------------------------------------

And the recipient handler:

  1. --------------------------------------------------------------------------------
  2. function NETWORK_chat.onReceivePlayername ( nUserID, sName )
  3. --------------------------------------------------------------------------------
  4. -- add player ID and their name to the table
  5. this.refreshChatUserlist ( 0, nUserID, sName )
  6. --------------------------------------------------------------------------------
  7. end
  8. --------------------------------------------------------------------------------

The custom function refreshChatUserlist ( nIDtoRemove, nIDtoAdd, sNameToAdd ) takes care of storing the username and its ID in a table and write these changes to the list component in the HUD. Since ShiVa does not allow for the creation of custom datatypes without plugins, the table is formatted in a way that the even fields are IDs and the odd fields are the name strings:

-- dataformat for player table:
    -- 0: player1_ID
    -- 1: player1_name
    -- 2: player2_ID
    -- 3: player2_name
    -- etc.

You can then work with offsets and stepping in for-loops to retrieve the relevant data:

  1. -- refresh HUD list
  2. local t = this._tUsernames ( )
  3. ts = table.getSize ( t )
  4. local hUser = this.getUser ( )
  5. local c = hud.getComponent ( hUser, "chat.list_users" )
  6. if c == nil then log.warning ( "chat.refreshChatUserlist: could not find user list component!" ) end
  7. -- clear all previous entries
  8. hud.removeListAllItems ( c )
  9. -- look at the "1" offset and "2" stepping in the for-loop
  10. local name = ""
  11. for j=1, ts-1, 2 do
  12. name = table.getAt ( t, j )
  13. hud.addListItem ( c, name )
  14. end

You should now have all the components to write a little chat application like this one:
result

TLDR;

1. Sessions are like chatrooms. You can only talk in one at a time. But servers can run many sessions at once.
2. Use scenes for broadcasting, even if you don't want to display 3D content.
3. No (user) handles as event parameters!
4. SendEvent additionally requires you to send userID, AIModel Name and Event name if you ever want a response. SendEventImmediate does not work in remote environments.
5. Session events and Scene events behave differently.


  • slackBanner