Computer Graphics Workshop '97 Lecture Notes

1/22/97

Today's topics
Networking

This lab will discuss some of the issues relating to creating a networked application. We will be using the SocketMan (Socket Manager) class library from within Scheme to support easy client-server and peer-peer networking.

Documentation for SocketMan is located in

/mit/iap-cgw/SocketMan/Documentation/SocketMan.doc

This documentation contains descriptions of all the available methods of the SocketMan and SocketClient classes. The SocketMan directory also contains some C++ code examples.

Example code in Scheme for some client-server and multicasting programs is located in

/mit/iap-cgw/Networking/

These programs' listings are also shown below.

SocketMan

SocketMan (short for Socket Manager) is a C++ class library designed to make implementing a networked application simple. By supporting several design abstractions, SocketMan can easily implement several types of networking models, such as client-server, peer-to-peer, and group networking (via IP multicasting).

There are three convenience functions defined in /mit/iap-cgw/lib/scm/SocketManInit.scm called write-object-to-network, read-object-from-network, and read-object-from-network-multi. write-object-to-network takes a Scheme object (list, vector, symbol, number; NOT an Inventor object like an SbVec3f) as its first argument and a socket interface (SocketMan or SocketClient object) as its second argument, and writes the Scheme object out to the network. read-object-from-network takes a socket interface as its first argument and attempts to read from it. If data was available, it returns the resulting Scheme object. read-object-from-network-multi is described in the section on multicasting, below.

When creating a SocketMan or SocketClient, you must always specify the port as the first argument to the constructor. This is merely a unique identifier which must be the same on both sides of your network connection. You should choose a random number between 10000 and 65535 as your port, and make sure that the program you load on all participating computers uses the same number everywhere.

Client-server

The client-server networking model involves one machine, the server, to which one or more clients connect. In the client-server networking model, clients do not connect to each other.

The SocketMan class handles connections to multiple clients transparently. The acceptConnectionWithPoll method takes no arguments and receives a connection from a client, if one is attempting to connect. Connections are automatically closed when the client disconnects. The rewindSockets and nextSocket methods allow iteration over all connected clients. rewindSockets begins an iteration loop; nextSocket returns 1 as long as there is still a socket to iterate over. When nextSocket returns 0, all of the connections have been serviced. (See the code in server.scm for an example of iterating over sockets.) The read-object-from-network and write-object-to-network functions operate on a Socket Manager's current socket.

client.scm and server.scm in this directory implement a client-server pair. The connect-and-send function in client.scm connects a SocketClient to a server and sends a message. Both the server and message are strings; for example:

(connect-and-send "m4-034-18" "Hi there!")
The send-to-clients function in server.scm writes the passed message to all currently connected clients:
(send-to-clients "Hello Clients!")
Peer-to-peer

Peer-to-peer networking is a special case of the client-server model, in which each "peer" connects to every other. This can be accomplished simply by creating both a SocketMan (which can only accept connections) and a SocketClient (which can only make connections) within the same application. The SocketMan object can be used to satisfy information requests from another host; the SocketClient object can be used to generate these requests.

However, since the same functionality of a peer-peer model can be implemented using either a client-server or group model (described below), it is recommended that you do not take this approach in your programs.

Group

Group networking, wherein multiple machines can all send messages to one another, is implemented in the SocketMan library via the use of the SocketClient class. When a SocketClient is in multicasting mode, it can connect to a special range of multicast addresses. All messages sent to a unique port on this address will then be automatically "multicast" to all clients connected to that address. The valid range of multicast IP addresses are

224.0.0.0 - 239.255.255.255

It is strongly recommended that you do not use "224" as the first part of your multicast addresses, to avoid interference with already-defined multicasting services.

Examples of multicasting code are in

/mit/iap-cgw/Networking/

A useful convenience routine, read-object-from-network-multi, takes a SocketClient in multicast mode as argument and attempts to read data from it. If data was available, it returns a pair (cons cell), where the car is the Scheme object read from the network, and the cdr is the address of the machine which sent the object to the network.

Knowledge of where a packet came from is useful when implementing network protocols among machines using IP multicasting. By default, messages multicast using a SocketClient do not get received at the transmitting machine (i.e., they do not loop back). Therefore, all received packets will be from other machines; for example, you could create an associative list (see assq and associated functions in the R4RS) of remote addresses paired with some state for each remote machine. Upon receiving a packet from a given remote machine, you could update your notion of what that machine's state is. (Keep in mind that you may only be able to run one copy of your application per machine, depending on what network protocol you define and whether you enable or disable multicast loopback.)

You can also find out your own IP address by using the getLocalIPAddress method of either SocketMan or SocketClient. As an example, you could define that each machine is responsible for detecting collisions between bullets and the ship that that mechine is running. When a collision is detected on machine B, it could send out a message that it was destroyed by a bullet fired from Machine A. By getting its local IP address and comparing it with the from address in this incoming message, Machine A could then increase its score.

The cone-sender program opens an examiner viewer with a cone inside a handle box manipulator. Dragging the cone around the scene causes the cone in any cone-receiver's window to follow the motion of the sender's cone. This program demonstrates sending Scheme vectors over the network.

The mcsender program demonstrates the ability to send differently typed data over the network, encapsulated within strings. The send-string function inside mcsender sends a string over the network; the send-SbVec3f function sends the contents of an SbVec3f. An idle callback checks for incoming data. Note that in this example multicast loopback is disabled (by default), so running two copies of mcsender on the same machine won't work.

Application Example

As an example, let's look at the network protocol for the Combat game we discussed in the last lecture. (Source code is in /mit/iap-cgw/src/combat.scm.) Combat sends four types of messages:
  1. new-pellet, to indicate that a new pellet has been created by some ship's firing
  2. key-state, which is sent out every time someone presses a key
  3. sync, which contains complete state information for a ship (including position, heading, and velocity)
  4. blown-up-by, which is sent out when a machine determines that the ship it is controlling has been blown up by a particular pellet.
Since sync messages are the largest, we want to minimize their frequency. This is done by only sending them once per second. The rest of the time the only information which is sent to update a ship is the user's key presses. Since all ships (not just the "local ship") internalize their key states (see the last lecture), this makes updating the positions of remote ships' "avatars" trivial. The key-press method seems to work well even with fairly infrequent sync updates; the primary reason for this is the fact that the ships' updates are synced to real time via the use of engines. (During development of the game, angular updates were computed by adding or subtracting a fixed constant to the current heading, depending on whether the ship was turning left or right; therefore, angular updates were framerate-dependent, and the synchronization was very poor. By simply multiplying this constant by real time (using a combination of an ElapsedTime and a Calculator engine), synchronization was improved a great deal.)

The new-pellet message contains both the current position and velocity vector of the pellet being created; new pellets aren't created by sending a message saying "Ship X just fired a bullet". Therefore, pellets will always traverse the same trajectory on every machine they appear on, although they will do so at slightly different times. This doesn't matter much since each machine is responsible for detecting collisions with the ship it's controlling.

The blown-up-by message is used primarily to notify the "killer" that he or she has scored a point. When other machines receive the blown-up-by message, they remove the appropriate ship from their scene graphs; only the destroyer increases his or her score.

All of these messages are implemented by writing out Scheme lists to the network. For example, here's the code in which the new-pellet message gets sent out. This piece of code is in the pellet's constructor. If the pellet determines that it has been created via a key press (and not from some other message coming in over the network), it sends out notification of its creation.

    (if (not *single-player-mode*)
	;; If from-where is NULL, then the local ship fired this shot.
	;; Send out notification to the network.
	(if (null? from-where)
	    (write-object-to-network
	     `(new-pellet ,(send starting-pos 'getValue)
			  ,(send velocity-vector 'getValue)
			  ,ship-velocity)
	     *sc*)))
Lower level details

This section describes the data structures and methods which are used to implement the convenience functions above.

The SocketMan classes (SocketMan and SocketClient) work by sending data structures known as SocketBufs amongst themselves. That is, when a SocketBuf is created on one machine and sent out to the network using either a Manager or Client, when it is received on another machine (almost) all of the fields will be the same. A SocketBuf is a very simple C++ class, of which we will be using only the following parts.

Methods from class SocketBuf:
SocketBuf();
void setBufFromString(char *new_value)

Fields from class SocketBuf:
char *buf;
unsigned long from_address; // for multicasting

The first method is the constructor for SocketBufs, which takes no arguments. The second is the setBufFromString method, which takes a Scheme string as argument and sets the internal buffer buf of the SocketBuf to be this string. For example, to create a new SocketBuf which contains the string "Hello world", we could do the following:

> (define my-sbuf (new-SocketBuf))
> (-> my-sbuf 'setBufFromString "Hello world")
The main field in SocketBufs is the buf field. (The from_address field is discussed below.) Note that this is not an Inventor-style field; when you send the buf message to a SocketBuf, it returns the string which the SocketBuf contains. For example, to extract the string from the above object, we could do:
> (define my-string (-> my-sbuf 'buf))
> my-string
"Hello world"
The other field, from_address, is used only when using SocketClients in multicast mode, and is filled in automatically with the address of the machine which sent a particular packet.

By using some built-in Scheme functions, and some provided by the Scheme Library, we can send Scheme objects over the network in the form of strings. To enable this functionality, we need to include the following two lines of code (which cause auxiliary library files to be loaded):

(require 'object->string)
(require 'string-port)
The object->string procedure takes a Scheme object and prints it into a string, returning this string as its value. This function will not work properly for Inventor objects, since they print their memory locations as their values; in order to send information between Scheme interpreters, you must use object->string on a native Scheme data type such as a list, vector, or number.

As an example, to put the string representing the Scheme vector of #(1.0 4.0 9.0) into a SocketBuf, we would do the following:

(define my-vec '#(1.0 4.0 9.0))
(define my-string (object->string my-vec))
(define my-sbuf (new-SocketBuf))
(-> my-sbuf 'setBufFromString my-string)
We can then send this SocketBuf using the methods described below.

On the receiving end, we need to convert the data in a SocketBuf back into a Scheme object. We can do this using the standard Scheme read command, combined with the Scheme Library's string-port extension:

(define the-new-string (-> my-sbuf 'buf))
(define the-new-object (call-with-input-string the-new-string read))
the-new-object would, in our above example, now be the vector from the other end. We can now access its values in the standard way:
> (vector-ref the-new-object 1)
4.0
The two primary methods which are used for communication (and which are present in both SocketMan and SocketClient) are readWithPollAndTimeout and writeWithTimeout. The former takes no arguments, and attempts to read in a SocketBuf from the network; the latter takes a SocketBuf as argument, and attempts to write it to the network. It returns a status code, which, at least for now, can be ignored.

Source code for examples

client.scm

;; Client half of client-server example. (Server is in server.scm).
;; Client connects to server and sends a string message. Uses an
;; IdleSensor to check for incoming messages from server, once
;; connected.

(define client (new-SocketClient 10732))
(send client 'setQuiet 1) ;; Otherwise, we'll get error messages
			  ;; when we try to read while disconnected

(define (connect-and-send server message)
  (send client 'closeConnection) ;; if necessary, close current connection
  (send client 'connectToServer server) ;; open new connection
  (write-object-to-network message client)
  )

(define (idle-cb user-data sensor)
  (let ((obj (read-object-from-network client)))
    (if (not (null? obj))
	(begin
	  (display obj)
	  (newline))))
  (send sensor 'schedule)) 

(define idle-sensor (sensor new-SoIdleSensor idle-cb))
(send idle-sensor 'schedule)
server.scm

;; Server half of client-server example. (Client is in client.scm).
;; Server uses IdleSensor to accept incoming connections and send
;; acknowledgements of them. User can use send-to-clients procedure
;; to send messages to all connected clients.

(define server (new-SocketMan 10732))

(define (receive-loop)
  (if (= 1 (send server 'nextSocket))
      (begin
	(let ((obj (read-object-from-network server)))
	  (if (not (null? obj))
	      (begin
		(display obj)
		(newline))))
	(receive-loop))))

(define (idle-cb user-data sensor)
  (if (= (send server 'acceptConnectionWithPoll) SocketMan::MSG_NO_ERROR)
      (format #t "Connection accepted\n"))
  (send server 'rewindSockets)
  (receive-loop)
  (send sensor 'schedule))

(define idle-sensor (sensor new-SoIdleSensor idle-cb))
(send idle-sensor 'schedule)

;; send-to-clients takes a string as argument.
;; For example: (send-to-clients "Hello clients!")
(define (send-to-clients message)
  (send server 'rewindSockets)
  (let loop
      ()
    (if (= 1 (send server 'nextSocket))
	(begin
	  (write-object-to-network message server)
	  (loop)))))
mcsender.scm

(define sc (new-SocketClient 10382))
(-> sc 'setUsingMulticast 1)
(-> sc 'connectToServer "224.6.6.6")

(define send-sbvec3f
  (lambda (the-sbvec)
    (let ((the-vec (-> the-sbvec 'getValue)))
      (write-object-to-network the-vec sc))))

(define send-string
  (lambda (the-string)
    (write-object-to-network the-string sc)))

;; Idle callback for receiving updates
(define (idle-cb user-data sensor)
  (let ((network-input (read-object-from-network-multi sc)))
    (if (not (null? network-input))
	(let ((obj (car network-input))
	      (addr (cdr network-input)))
	  (if (vector? obj)
	      (begin
		(display "Got vector from ")
		(display addr)
		(newline)
		(display "Vector values: ")
		(for-each
		 (lambda (i)
		   (display i)
		   (display " "))
		 (vector->list obj))
		(newline))
	      (begin
		(display "Got string from ")
		(display addr)
		(newline)
		(display "String contents: ")
		(display obj)
		(newline))))))
  (-> sensor 'schedule))

;; Idle sensor
(define idle-sensor (sensor new-SoIdleSensor idle-cb))
(-> idle-sensor 'schedule)
cone-sender.scm

(load "cone-network")

(define root (new-SoSeparator))
(-> root 'ref)

(define mat (new-SoMaterial))
(-> (-> mat 'diffuseColor) 'setValue 0.2 0.8 0.2)
(-> root 'addChild mat)

(define manip (new-SoHandleBoxManip))
(-> root 'addChild manip)

(-> root 'addChild (new-SoCone))

(define node-changed-cb
  (lambda (user-data sensor)
    (let ((get-matrix-action (new-SoGetMatrixAction
			      (-> sender-viewer 'getViewportRegion))))
      (-> get-matrix-action 'apply manip)
      (let ((mat (-> (-> get-matrix-action 'getMatrix) 'getValue)))
	(write-object-to-network mat sc)))))

(define sender-viewer (examiner root))
(-> sender-viewer 'setViewing 0)

;; Create node sensor for manipulator. 
(define node-sensor (sensor new-SoNodeSensor node-changed-cb))
(-> node-sensor 'attach manip)
cone-receiver.scm

(load "cone-network")

(define root (new-SoSeparator))
(-> root 'ref)

(define mat (new-SoMaterial))
(-> (-> mat 'diffuseColor) 'setValue 0.2 0.8 0.2)
(-> root 'addChild mat)

(define xform (new-SoTransform))
(-> root 'addChild xform)

(-> root 'addChild (new-SoCone))

(define receiver-viewer (examiner root))

(define (idle-cb user-data sensor)
  (-> sensor 'schedule)
  (let ((mat (read-object-from-network sc)))
    (if (not (null? mat))
	(-> xform 'setMatrix (new-SbMatrix mat)))))

(define idle-sensor (sensor new-SoIdleSensor idle-cb))
(-> idle-sensor 'schedule)

(-> receiver-viewer 'setSceneGraph root)
cone-network.scm

(define *cone-sender-port* 13742)
(define *cone-sender-address* "224.4.8.16")

(define sc (new-SocketClient *cone-sender-port*))
(-> sc 'setUsingMulticast 1)
(-> sc 'connectToServer *cone-sender-address*)

;; Allow both sender and receiver to run on the same machine
(-> sc 'setSuppressingMulticastLoopback 0)

Back to the CGW '97 home page

$Id: index.html,v 1.5 1997/01/22 23:32:31 kbrussel Exp $