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.  
  5. -- fill server info table with some default data
  6. local serverinfo = this._htServer ( )
  7. hashtable.add ( serverinfo, "ip", "127.0.0.1" )
  8. hashtable.add ( serverinfo, "port", "5354" )
  9. hashtable.add ( serverinfo, "session", "Default" )
  10. hashtable.add ( serverinfo, "isConnected", false )
  11. hashtable.add ( serverinfo, "isPending", false )
  12. hashtable.add ( serverinfo, "hasScannedForSessions", false )
  13.  
  14. -- same for player info
  15. local playerinfo = this._htPlayer ( )
  16. hashtable.add ( playerinfo, "id", 0 )
  17. hashtable.add ( playerinfo, "name", "ShiVaUser" ..math.floor ( math.random ( 10, 100 ) ) )
  18.  
  19. --------------------------------------------------------------------------------
  20. end
  21. --------------------------------------------------------------------------------

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.  
  5. log.message ( "EVENT: " ..nOldUserID .." changed their ID to " ..nNewUserID )
  6.  
  7. -- overwrite player ID in info hashtable for easy lookup
  8. local playerinfo = this._htPlayer ( )
  9. local playerID = hashtable.get ( playerinfo, "id" )
  10.  
  11. if playerID == nOldUserID then
  12. hashtable.set ( playerinfo, "id", nNewUserID )
  13. log.message ( "My new Player ID is: " ..nNewUserID )
  14. end
  15.  
  16. --------------------------------------------------------------------------------
  17. end
  18. --------------------------------------------------------------------------------

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

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.  
  5. if hScene == nil then
  6. log.warning ( "NETWORK_chat.chat_onEnter: could not verify scene handle" )
  7. else
  8. -- get all users in scene for username table
  9. local nUsers = scene.getUserCount ( hScene )
  10. local rUser = nil
  11. for i=0, nUsers-1 do
  12. rUser = scene.getUserAt ( hScene, i )
  13. -- don't worry about the following event, we will explain it later
  14. user.sendEvent ( rUser, "NETWORK_connect", "onQueryPlayerInfo", "name", user.getID ( this.getUser ( ) ), "NETWORK_chat", "onReceivePlayername" )
  15. end
  16. 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.  
  5. local hUser = this.getUser ( )
  6.  
  7. -- check if we are allowed to send
  8. if this._bChatting ( ) == false then return end
  9.  
  10. -- boring: get UI component, getLabelText, setLabelText="", return text
  11. local m = this.getInputLineText ( )
  12. if m == nil then return end -- ignore if empty
  13.  
  14. -- broadcast section
  15.  
  16. local hScene = application.getCurrentUserScene ( )
  17. -- call a custom event which returns the name of your own player
  18. local myname = user.sendEventImmediate ( hUser, "NETWORK_connect", "onQueryPlayerInfo", "name", nil, nil, nil )
  19. -- broadcast the name to a custom AI Event (which all users have) which adds the player name to a table
  20. scene.sendEventToAllUsers ( hScene, "NETWORK_chat", "onReceiveChatmessage", myname ..": " ..m )
  21.  
  22. --------------------------------------------------------------------------------
  23. end
  24. --------------------------------------------------------------------------------

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.  
  5. local ht = this._htPlayer ( )
  6. local storedKey = hashtable.get ( ht, sKey )
  7. return storedKey
  8.  
  9. --------------------------------------------------------------------------------
  10. end
  11. --------------------------------------------------------------------------------

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.  
  5. local ht = this._htPlayer ( )
  6. local storedKey = hashtable.get ( ht, sKey )
  7.  
  8. if nAnswerToUserID ~= nil then
  9. local hAnswerToUser = application.getUser ( nAnswerToUserID )
  10. if hAnswerToUser == nil then log.warning ( "connect.onQueryPlayerInfo: could not resolve user handle!" ) return storedKey end
  11.  
  12. user.sendEvent ( hAnswerToUser, sAnswerToAI, sAnswerToEvent, user.getID ( this.getUser ( ) ), storedKey )
  13. end
  14.  
  15. return storedKey
  16.  
  17. --------------------------------------------------------------------------------
  18. end
  19. --------------------------------------------------------------------------------

And the recipient handler:

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

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.  
  10. -- look at the "1" offset and "2" stepping in the for-loop
  11. local name = ""
  12. for j=1, ts-1, 2 do
  13. name = table.getAt ( t, j )
  14. hud.addListItem ( c, name )
  15. 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.