// Host and Client (hC) Module
// hostAndClient.js
   console.log('HC version 2.51');
// 5:15 PM Mon November 16, 2020
// Written by: James D. Miller

/*
Dependencies for hostAndClient.js:
   constructorsAndPrototypes.js (cP.)
   gwModule.js (gW.)
   utilities.js

hostAndClient.js communicates with server functionality in server.js running
at Heroku or on a local node server.

*/

var hC = (function() {
   
   "use strict";
   
   // Globals within hC. /////////////////////////////////////////////////
   
   var socket = null;
   var nodeServerURL, serverArray;
   var chatStyleToggle = true;
   
   var timer = {};
   timer.start = null;
   timer.end = null;
   timer.pingArray = [];
   
   var clientDeviceType;
   var clientCanvas, ctx;
   var clientCanvas_tt, ctx_tt;
   var videoMirror, videoMirrorDiv, videoStream;
   var chkRequestStream, chkLocalCursor;
   
   var chkTwoThumbs, btnTwoThumbs, twoThumbs;
   var btnFullScreen;
   var chkPlayer;
   
   var myRequest;
      
   // Key values.
   var keyMap = {'16':'sh','17':'ct','18':'alt','32':'sp',  //sh:shift, ct:ctrl, sp:space
                 '49':'1', '50':'2', '51':'3', '52':'4', '53':'5', '54':'6', '55':'7', '56':'8', '57':'9',
                 '70':'f', 
                 '65':'a', '83':'s', '68':'d', '87':'w',
                 '74':'j', '75':'k', '76':'l', '73':'i', '90':'z',
                 '191':'cl'};  // cl (short for color), 191 is the question-mark key.
   // Mouse and keyboard (mK) from non-host clients.
   var mK = {};
   mK.name = null;
   
   // Key values, cso (client side only) for use only by the client, not to be sent over network
   // to the host.
   var keyMap_cso = {'16':'key_shift', '17':'key_ctrl', '27':'key_esc', '80':'key_p'}
   var mK_cso = {};
   
   
   // The client name of this user. This global is only used on the client page and
   // is some increment of u1, u2, etc for network clients.
   var newClientName = null;
   // cl is a global that points at a "Client" or clientlike object. For the host, this will point at the
   // "Client" object of the client that most recently attempts to connect. On the client page, 
   // this will simply keep this structure and replace the null with the name of that
   // client.
   var cl = {'name':null, 'previous_name':null};
   var rtc_choke = false;
   
   // connection history and metrics
   var connHist = {};
   connHist.connectListenCounts = 0;
   
   var fileName = "hostAndClient.js";
   
   // supporting touch-screen event processing
   var ts = {};
   ts.previousTapTime = new Date().getTime();
   ts.tapCount = 1;
   
   // Pacifier (connecting status) string for connecting...
   var pacifier = {};
   
   // Switches to enable debugging...
   var db = {};
   db.rtc = false; // WebRTC debug.
   
   // A few globals (gb) that get exposure to other modules.
   var gb = {};
   gb.gameReportCounter = 0;
   gb.touchScreenUsage_sendCounter = 0;
   
   //////////////////////////////////////////////////   
   // Object prototypes
   //////////////////////////////////////////////////   
   
   function RTC( pars) {
      this.user1 = setDefault( pars.user1, null);
      this.user2 = setDefault( pars.user2, null);
      this.streamRequested = setDefault( pars.streamRequested, null);
      
      this.pc = null;
      this.dataChannel = null;
   }
   RTC.prototype.shutdown = function() {
      //console.log('pc:'+JSON.stringify(this.pc));
      //console.log('dataChannel:'+JSON.stringify(this.dataChannel));
      
      // Close then nullify any references to the datachannel and the p2p connection.
      if (this.dataChannel) {
         this.dataChannel.close();
      }
      if (this.pc) {
         var senders = this.pc.getSenders();
         if (senders.length >= 1) {
            //console.log('senders length = ' + senders.length);
            this.pc.removeTrack( senders[0]);
            
            senders = this.pc.getSenders();
            //console.log('senders length = ' + senders.length);
         }
         this.pc.close();
      }
      if (this.dataChannel) {
         this.dataChannel = null;
      }
      if (this.pc) {
         this.pc = null;
      }
   }
   // This method works only on the host side of the WebRTC connection. So, that's why there's a check here
   // to see if user1 is the host.
   RTC.prototype.turnVideoStreamOff = function() {
      if (this.pc && (this.user1 == 'host')) {
         var senders = this.pc.getSenders();
         //console.log('senders length (before) = ' + senders.length);
         if (senders.length >= 1) {
            this.pc.removeTrack( senders[0]);
            //senders = this.pc.getSenders();
            //console.log('senders length (after) = ' + senders.length);
         }
      }
   }
   
   
   function TwoThumbs( pars) {
      cP.DrawingFunctions.call(this); // inherit
      
      // Not yet using this adjustment point feature (TBD).
      //this.adjustmentPoint_2d = new cP.Vec2D(0, 0);
      this.enabled = false;
      
      // Grid of rectangles. UL (upper left corner), LR (lower right)
      this.grid = {
         'jet_360':     {'active':false , 'mK':'w',   'UL':null, 'LR':null, 'dir_2d':null},
         'gun_360':     {'active':false , 'mK':'i',   'UL':null, 'LR':null, 'dir_2d':null},
         'shield':      {'active':false , 'mK':'sp',  'UL':null, 'LR':null},
         'color':       {'active':false , 'mK':'cl',  'UL':null, 'LR':null},
         
         'alt':         {'active':false , 'mK':null,  'UL':null, 'LR':null},
         
         // Controls that are dependent on the alt rectangle being touched.
         'esc':         {'active':false , 'mK':null,  'UL':null, 'LR':null},
         'demo7':       {'active':false , 'mK':'7',   'UL':null, 'LR':null},
         'demo8':       {'active':false , 'mK':'8',   'UL':null, 'LR':null},
         'freeze':      {'active':false , 'mK':'f',   'UL':null, 'LR':null},
         
         // Secondary control that fires the gun. Changes angle by controlling the rotation rate.
         'gun_scope':   {'active':false , 'mK':'ScTr','UL':null, 'LR':null, 'x_fraction':0}
      };
            
      // This is the same for both the jet and the gun.
      this.statusDotRadius_fraction =  0.020;
      
      // Control radius in units of screen fraction. The jet has four
      // strength levels: <1, >1 && <2, >2 && <3, >3.
      this.grid['jet_360'].cRadius_1_f = 0.050;  //0.090
      this.grid['jet_360'].cRadius_2_f = 0.090;  //0.130
      this.grid['jet_360'].cRadius_3_f = 0.130;  //0.170
      
      this.jetRadiusColor_3 = "rgb(255,   0,   0)";
      this.jetRadiusColor_2 = "rgb(200,   0,   0)";
      this.jetRadiusColor_1 = "rgb(140,   0,   0)";
      this.jetRadiusColor_0 = "rgb( 50,   0,   0)";
      
      // The gun has zero level, for bluffing. All touches outside that ring
      // are for firing.
      this.grid['gun_360'].cRadius_0_f = 0.040; //0.060
      
      this.gunRadiusColor_0 = "rgb(255,   0,   0)";
      
      this.bgColor = 'lightgray';
      this.gridColor = '#232323'; // very dark gray // #008080 dark green
      clientCanvas_tt.style.borderColor = this.gridColor;
      
      // 0.10 uses 10% of the rectangle width for the dead spot.
      this.scopeShootSpot = 0.20;
      
      this.updateAndDrawTouchGrid('updateOnly');
      
      this.puckPopped = true;
   }
   TwoThumbs.prototype = Object.create( cP.DrawingFunctions.prototype); // Inherit methods
   TwoThumbs.prototype.constructor = TwoThumbs; // Rename the constructor (after inheriting)
   // Calculate point position in canvas coordinates as a function of fractional position.
   TwoThumbs.prototype.absPos_x_px = function( fraction) {
      return Math.round(fraction * clientCanvas_tt.width);
   }
   TwoThumbs.prototype.absPos_y_px = function( fraction) {
      return Math.round(fraction * clientCanvas_tt.height);
   }
   TwoThumbs.prototype.resetRectangle = function( rectName) {
      // Update the target rectangle to reflect that there is no touch point in it.
      var rect = this.grid[ rectName];
      // The alt rectangle cases:
      if ((rectName =='esc' || rectName =='demo7' || rectName =='demo8' || rectName =='freeze')) {
         if (this.grid['alt'].active) {
            this.updateStatusDot( rectName, this.gridColor);
         } else {
            this.updateStatusDot( rectName, this.bgColor);
         }
      // The others...
      } else {
         if ((rectName == 'alt') || (rectName == 'shield')) {
            this.updateStatusDot( rectName, this.gridColor);
            
         } else if (rectName == 'color') {
            if (cl.name) this.colorClientRect( clientColor( cl.name));
         
         } else if (rectName == 'jet_360') {
            this.updateStatusDot( rectName, this.gridColor);
            mK.jet_d = null; // jet angle in degrees
            
         } else if (rectName == 'gun_360') {
            this.updateStatusDot( rectName, this.gridColor);
            mK.gun_d = null; // gun angle in degrees
            
         } else if (rectName == 'gun_scope') {
            this.updateStatusDot( rectName, this.gridColor);
            // Scope Rotation Rate Fraction (ScRrf).
            mK.ScRrf = 0.00;
         }
      }
      // For all rectangles: deactivate and reset the primary mK attribute for that square.
      rect.active = false;
      if (rect.mK) mK[rect.mK] = 'U';
   }
   TwoThumbs.prototype.processMultiTouch = function( touchVectors_2d_px) {
      for (var rectName in this.grid) {
         var rect = this.grid[ rectName];
         
         var atLeastOnePointInRect = false;
         for (var i = 0, len = touchVectors_2d_px.length; i < len; i++) {
            var p_2d = touchVectors_2d_px[i];
            
            if ( (p_2d.x > rect.UL.x) && (p_2d.x < rect.LR.x) && (p_2d.y > rect.UL.y) && (p_2d.y < rect.LR.y) ) {
               this.updateRectangle( rectName, p_2d);
               atLeastOnePointInRect = true;
               break;
            }
         }
         // If no touch point in this rectangle...
         if ( ! atLeastOnePointInRect) {
            this.resetRectangle( rectName);
         }
      }
      handle_sending_mK_data( mK);
   }
   TwoThumbs.prototype.processSingleTouchRelease = function( touchVector_2d_px) {
      for (var rectName in this.grid) {
         var rect = this.grid[ rectName];
         var p_2d = touchVector_2d_px;
         if ( (p_2d.x > rect.UL.x) && (p_2d.x < rect.LR.x) && (p_2d.y > rect.UL.y) && (p_2d.y < rect.LR.y) ) {
            
            this.resetRectangle( rectName);
            if (rectName == 'gun_scope') this.updateStatusDot('gun_360', this.gridColor, false);
            
            // When you release the alt rectangle, show as sleeping (not listening),
            // those rectangles that are dependent on the alt rectangle.
            if (rectName == 'alt') {
               this.updateStatusDot('esc',   this.bgColor);
               this.updateStatusDot('demo7', this.bgColor);
               this.updateStatusDot('demo8', this.bgColor);
               this.updateStatusDot('freeze',this.bgColor);
            }
            break;
         }
      }
   }
   TwoThumbs.prototype.processGunAngleFromHost = function( data) {
      // Update the orientation vector
      var rect = this.grid['gun_360'];
      rect.dir_2d.set_angle( -data['gunAngle']);
      rect.endPoint_2d = rect.center_2d.add( rect.dir_2d);
      // Draw it
      this.updateStatusDot('gun_360', this.gridColor);
   }
   TwoThumbs.prototype.processJetAngleFromHost = function( data) {
      // Update the orientation vector
      var rect = this.grid['jet_360'];
      // (also see updateRectangle)
      rect.dir_2d.set_angle(180 - data['jetAngle']);
      rect.endPoint_2d = rect.center_2d.subtract( rect.dir_2d);
      // Draw it
      this.updateStatusDot('jet_360', this.gridColor);
   }
   TwoThumbs.prototype.updateStatusDot = function( rectName, statusDotColor, dotOnly = false) {
      var rect = this.grid[ rectName];
      
      // Draw a square over the prior status dot. This prevents a jagged edge when the status
      // dot is drawn. And is a little more efficient then drawing the whole rectangle for each update.
      // The eraser rectangle is a little larger than the dot.
      var extraPx = 3;
      ctx_tt.fillStyle = this.bgColor;
      // upper left corner: x,y, width, height
      ctx_tt.fillRect( rect.center_2d.x - this.statusDotRadius_px - extraPx, rect.center_2d.y - this.statusDotRadius_px - extraPx, 
                       (this.statusDotRadius_px * 2) + extraPx*2, 
                       (this.statusDotRadius_px * 2) + extraPx*2);
      
      // Draw the dot.
      if (rectName == 'gun_scope') {
         var upperLeft_2d_px = rect.center_2d.subtract( new cP.Vec2D(this.statusDotRadius_px, this.statusDotRadius_px));
         this.fillRectangle( ctx_tt, upperLeft_2d_px, {'width_px':this.statusDotRadius_px * 2, 'height_px':this.statusDotRadius_px * 1, 'fillColor':statusDotColor});   
      } else {
         this.drawCircle( ctx_tt, rect.center_2d, {'radius_px':this.statusDotRadius_px, 'fillColor':statusDotColor} );
      }
      
      if ( ! dotOnly) {
         if ((rectName == 'jet_360') || (rectName == 'gun_360')) {
            // Draw the direction line.
            this.drawLine( ctx_tt, rect.center_2d, rect.endPoint_2d, {'width_px':3, 'color':'white'} );
            
            // Draw nosecone
            if (rectName == 'jet_360') {
               var nose_cone_2d = rect.center_2d.add( rect.dir_2d.scaleBy(0.75));
               this.drawCircle( ctx_tt, nose_cone_2d, {'fillColor': 'white', 'radius_px':this.absPos_x_px(0.005)} );
            }
         } else if (rectName == 'gun_scope') {
            // Draw line to indicate the rate of rotation
            if (Math.abs(rect.x_fraction) > this.scopeShootSpot) {
               var rotIndicator_start_2d = rect.center_2d.add( new cP.Vec2D(0,                                         -this.statusDotRadius_px * 0.5));
               var rotIndicator_end_2d   = rect.center_2d.add( new cP.Vec2D(this.statusDotRadius_px * rect.x_fraction, -this.statusDotRadius_px * 0.5));
               this.drawLine( ctx_tt, rotIndicator_start_2d, rotIndicator_end_2d, {'width_px':this.statusDotRadius_px * 0.5, 'color':'black'} );
            }
         }
      }
   }
   TwoThumbs.prototype.updateRectangle = function( rectName, point_2d) {
      var rect = this.grid[ rectName];
      var statusDotColor;
      
      var relativeToCenter_2d = point_2d.subtract( rect.center_2d);
      
      if (rectName == 'jet_360' || rectName == 'gun_360') {
         var rTC_lengthSquared = relativeToCenter_2d.length_squared();
         // Orient dir_2d to match the direction of relativeToCenter_2d
         // Note the negative sign correction (on the angle result) is necessary because of the 
         // negative orientation of the y axis with the screen (pixels) representation (not world here).
         var angle_d = -rect.dir_2d.matchAngle( relativeToCenter_2d);
         
         if (rectName == 'jet_360') {
            // Orient the tube in the opposite direction from the touch point.
            rect.endPoint_2d = rect.center_2d.subtract( rect.dir_2d);
            
            // Check where the point is relative to the control rings.
            
            // Always use at least the minimum jet power.
            statusDotColor = this.jetRadiusColor_0;
            mK.jet_t = 0.1; // Jet throttle
            
            // Stronger jet
            if (rTC_lengthSquared > Math.pow(this.grid['jet_360'].cRadius_1_px, 2)) {
               statusDotColor = this.jetRadiusColor_1;
               mK.jet_t = 0.4; 
               
               // Even stronger jet
               if (rTC_lengthSquared > Math.pow(this.grid['jet_360'].cRadius_2_px, 2)) {
                  statusDotColor = this.jetRadiusColor_2;
                  mK.jet_t = 0.7; 
                  
                  // Even stronger jet
                  if (rTC_lengthSquared > Math.pow(this.grid['jet_360'].cRadius_3_px, 2)) {
                     statusDotColor = this.jetRadiusColor_3;
                     mK.jet_t = 1.0;
                  }                  
               }
            }
            // Update mK for sending to the host.
            mK.w = 'D';
            mK.jet_d = angle_d + 180; // Use 180 to aim the nose cone; 0 to aim the jet tube
            
         } else if (rectName == 'gun_360') {
            // Orient the tube in the same direction from the touch point.
            rect.endPoint_2d = rect.center_2d.add( rect.dir_2d);
            
            // Check is the point is outside the control ring...
            if (rTC_lengthSquared > Math.pow(this.grid['gun_360'].cRadius_0_px, 2)) {
               statusDotColor = this.gunRadiusColor_0;
               mK.i = 'D';
            } else {
               statusDotColor = this.gridColor;
               mK.i = 'U';
            }
            // Update mK for sending to the host.
            mK.gun_d = angle_d;
         }
         this.updateStatusDot( rectName, statusDotColor);
        
      } else if (rectName == 'shield') {
         if (rect.mK) mK[rect.mK] = 'D';
         this.updateStatusDot( rectName, 'yellow');
         
      } else if (rectName == 'color') {
         //           mK.cl       = 'D'
         if (rect.mK) mK[rect.mK] = 'D';
         this.colorClientRect( this.bgColor);
         
      } else if (rectName == 'gun_scope') {
         // Rotation rate fraction (Rrf) for the scope control (Sc), where x_fraction varies 
         // from -1 to +1;
         rect.x_fraction = relativeToCenter_2d.x / ((rect.LR.x - rect.UL.x)/2.0);
         var x_fraction_abs = Math.abs( rect.x_fraction);
         if (x_fraction_abs > 0) {
            var x_fraction_sign = rect.x_fraction / x_fraction_abs;
         } else {
            var x_fraction_sign = 1.0;
         }
         
         // Shooting spot in the middle where it will only shoot, not rotate.
         if (x_fraction_abs < this.scopeShootSpot) {
            var x_fraction_mapped = 0.00;
            mK[rect.mK] = 'D';
            this.updateStatusDot( rectName, 'red');
            this.updateStatusDot('gun_360', 'red', false);
         
         // The outer areas will only rotate, not shoot.
         } else {
            // Map the x_fraction value so that near the edge of the dead zone, the rate is small. At the
            // outer edge of the rectangle, the rate is 1.0 times the normal keyboard rotation rate.
            var x_fraction_mapped = x_fraction_sign * (x_fraction_abs - this.scopeShootSpot - 0.01) * 1.0;
            mK[rect.mK] = 'U';
            this.updateStatusDot( rectName, 'yellow');
         }
         // Scope Rotation Rate Fraction (ScRrf)
         mK['ScRrf'] = x_fraction_mapped.toFixed(2);
         
      } else if (rectName == 'alt') {
         this.updateStatusDot( rectName, 'yellow');
         // Show the alt-dependent rectangles as awake (ready to receive a touch). Don't do this
         // if the alt rectangle is already active. This check is necessary to allow the alt keys
         // to show yellow after they are touched. Remember, the updateRectangle function fires
         // twice when using the alt feature.
         if (!this.grid['alt'].active) {
            this.updateStatusDot('esc',   this.gridColor);
            this.updateStatusDot('demo7', this.gridColor);
            this.updateStatusDot('demo8', this.gridColor);
            this.updateStatusDot('freeze',this.gridColor);
         }
      
      // Must use the alt button for these:
      } else if (this.grid['alt'].active && (rectName =='esc' || rectName =='demo7' || rectName =='demo8' || rectName =='freeze')) {
         if (rectName =='esc') {
            clientCanvas_tt.width  = videoMirror.width;
            clientCanvas_tt.height = videoMirror.height;
            // Note: the alt and esc rectangles get "released" in this call to changeDisplay.
            this.changeDisplay('exit');
            return;
         }
         if (rect.mK) mK[rect.mK] = 'D';
         this.updateStatusDot( rectName, 'yellow');
      }
      
      // No matter what, set this rectangle to be active.
      rect.active = true;
   }
   // Color the rectangle that indicates the client color.
   TwoThumbs.prototype.colorClientRect = function( color) {
      // Draw this a little smaller than the actual rectangle.
      var shrink_px = 8;
      var ULx = this.grid['color'].UL.x + shrink_px;
      var ULy = this.grid['color'].UL.y + shrink_px;
      var LRx = this.grid['color'].LR.x - shrink_px;
      var LRy = this.grid['color'].LR.y - shrink_px;
      
      var width_px = LRx - ULx;
      var height_px = LRy - ULy;
      
      ctx_tt.fillStyle = color;
      ctx_tt.fillRect(ULx, ULy, width_px, height_px);
      
      // Circle to reflect how it looks on the host
      if ((color == this.bgColor) && ( ! this.puckPopped)) {
         this.drawCircle( ctx_tt, this.grid['color'].center_2d, 
            {'radius_px':this.statusDotRadius_px * 2.5, 'fillColor':clientColor( cl.name)} );
      }
      
      // Add circle to indicate that you still have a puck to drive.
      if ( ! this.puckPopped) {
         this.drawCircle( ctx_tt, this.grid['color'].center_2d, {'radius_px':this.statusDotRadius_px, 'fillColor':this.gridColor} );
      }
   }
   TwoThumbs.prototype.scaledFont = function( fontInt) {
      var myFudge = 1.5;
      var scaledFontValue = myFudge * fontInt * (window.innerWidth * window.devicePixelRatio / 1920);
      return scaledFontValue.toFixed(1) + "px Arial";
      
   }
   TwoThumbs.prototype.updateAndDrawTouchGrid = function( mode) {      
      ctx_tt.fillStyle = this.bgColor;
      ctx_tt.fillRect(0,0, clientCanvas_tt.width, clientCanvas_tt.height);
      
      //this.adjustmentPoint_2d.x = this.absPos_x_px( 0.47);
      //this.adjustmentPoint_2d.y = this.absPos_y_px( 0.90);
      
      this.statusDotRadius_px = this.absPos_x_px(this.statusDotRadius_fraction);
      
      this.grid['jet_360'].cRadius_1_px = this.absPos_x_px(this.grid['jet_360'].cRadius_1_f);
      this.grid['jet_360'].cRadius_2_px = this.absPos_x_px(this.grid['jet_360'].cRadius_2_f);
      this.grid['jet_360'].cRadius_3_px = this.absPos_x_px(this.grid['jet_360'].cRadius_3_f);
      
      this.grid['gun_360'].cRadius_0_px = this.absPos_x_px(this.grid['gun_360'].cRadius_0_f);
      
      // x position of the vertical lines (from left to right).
      var x0  = this.absPos_x_px( 0.00);
      var x0a = this.absPos_x_px( 0.10);
      var x0b = this.absPos_x_px( 0.20);
      var x0c = this.absPos_x_px( 0.30);
      var x0d = this.absPos_x_px( 0.315);
      var x0e = this.absPos_x_px( 0.455);
      
      var x1 = this.absPos_x_px( 0.47);
      var x2 = this.absPos_x_px( 0.60);    
      var x3 = this.absPos_x_px( 1.00);
      // Center +/- the half width of the scope spot.
      var x2a = (x3 + x2)/2.0 - ((x3-x2) * this.scopeShootSpot/2.0);
      var x2b = (x3 + x2)/2.0 + ((x3-x2) * this.scopeShootSpot/2.0);
      
      // y position of the horizontal lines (from top to bottom).
      var y0  = this.absPos_y_px( 0.00);
      var y0a = this.absPos_y_px( 0.65);
      var y0b = this.absPos_y_px( 0.85);
      var y1  = this.absPos_y_px( 0.90);
      var y2  = this.absPos_y_px( 1.00);
      
      // Define all the rectangles in the grid. UL: upper left, LR: lower right.
      this.grid['jet_360'].UL = new cP.Vec2D(x0, y0);
      this.grid['jet_360'].LR = new cP.Vec2D(x1, y1);
      this.grid['jet_360'].dir_2d = new cP.Vec2D(0, -this.statusDotRadius_px); // as if touch point is high
         
      this.grid['gun_360'].UL = new cP.Vec2D(x2, y0);
      this.grid['gun_360'].LR = new cP.Vec2D(x3, y0b);
      this.grid['gun_360'].dir_2d = new cP.Vec2D(0, -this.statusDotRadius_px); // as if touch point is high
      
      this.grid['shield'].UL = new cP.Vec2D(x1, y0);
      this.grid['shield'].LR = new cP.Vec2D(x2, y0a);
      
      this.grid['color'].UL = new cP.Vec2D(x1, y0a);
      this.grid['color'].LR = new cP.Vec2D(x2, y0b);
      
      this.grid['freeze'].UL = new cP.Vec2D(x0, y1);
      this.grid['freeze'].LR = new cP.Vec2D(x0a, y2);
      
      this.grid['demo7'].UL = new cP.Vec2D(x0a, y1);
      this.grid['demo7'].LR = new cP.Vec2D(x0b, y2);
      
      this.grid['demo8'].UL = new cP.Vec2D(x0b, y1);
      this.grid['demo8'].LR = new cP.Vec2D(x0c, y2);
      
      this.grid['esc'].UL = new cP.Vec2D(x0d, y1);
      this.grid['esc'].LR = new cP.Vec2D(x0e, y2);
      
      this.grid['alt'].UL = new cP.Vec2D(x1, y1);
      this.grid['alt'].LR = new cP.Vec2D(x2, y2);
         
      this.grid['gun_scope'].UL = new cP.Vec2D(x2, y0b);
      this.grid['gun_scope'].LR = new cP.Vec2D(x3, y2);
      
      // Calculate the center point of each rectangle.
      for (var rectName in this.grid) {
         var rect = this.grid[ rectName];
         rect.center_2d = rect.UL.add( rect.LR).scaleBy(1.0/2.0);
         if ((rectName == "jet_360") || (rectName == "gun_360")) {
            // endPoint calculations for jet and gun
            if (rectName == 'jet_360') {
               // This endPoint calc will orient the jet tube in the opposite direction from the initial direction of dir_2d.
               rect.endPoint_2d = rect.center_2d.subtract( rect.dir_2d);
            } else if (rectName == 'gun_360') {
               // Orient gun tube in same direction as dir_2d
               rect.endPoint_2d = rect.center_2d.add( rect.dir_2d);
            }
         }
      }
      
      if (mode == 'draw') {
         // Draw grid...
         // Vertical lines
         this.drawLine( ctx_tt, new cP.Vec2D(x0a, y1), new cP.Vec2D(x0a, y2), {'width_px':3, 'color':this.gridColor});
         this.drawLine( ctx_tt, new cP.Vec2D(x0b, y1), new cP.Vec2D(x0b, y2), {'width_px':3, 'color':this.gridColor});
         this.drawLine( ctx_tt, new cP.Vec2D(x0c, y1), new cP.Vec2D(x0c, y2), {'width_px':3, 'color':this.gridColor});
         this.drawLine( ctx_tt, new cP.Vec2D(x0d, y1), new cP.Vec2D(x0d, y2), {'width_px':3, 'color':this.gridColor});
         this.drawLine( ctx_tt, new cP.Vec2D(x0e, y1), new cP.Vec2D(x0e, y2), {'width_px':3, 'color':this.gridColor});
         this.drawLine( ctx_tt, new cP.Vec2D(x1, y0),  new cP.Vec2D(x1, y2),  {'width_px':5, 'color':this.gridColor});
         this.drawLine( ctx_tt, new cP.Vec2D(x2, y0),  new cP.Vec2D(x2, y2),  {'width_px':5, 'color':this.gridColor});
         
         // Vertical lines in the scope rectangle
         this.drawLine( ctx_tt, new cP.Vec2D(x2a, y0b), new cP.Vec2D(x2a, y2), {'width_px':1, 'color':this.gridColor});
         this.drawLine( ctx_tt, new cP.Vec2D(x2b, y0b), new cP.Vec2D(x2b, y2), {'width_px':1, 'color':this.gridColor});
         
         // Draw the vertical gradient lines in the scope rectangle.
         var width_px = x2a - x2;
         var step_px = Math.round(width_px/10.0);
         var length_px = Math.round((y2 - y0b)/3.0);
         for (var i = step_px; i < width_px; i += step_px) {
            this.drawLine( ctx_tt, new cP.Vec2D(x2 + i, y2-length_px), new cP.Vec2D(x2 + i, y2), {'width_px':1, 'color':this.gridColor});
            this.drawLine( ctx_tt, new cP.Vec2D(x3 - i, y2-length_px), new cP.Vec2D(x3 - i, y2), {'width_px':1, 'color':this.gridColor});
            step_px *= 0.91; // reduce the step
            if (step_px < 1) step_px = 1;
         }
         
         // Horizontal lines
         // First two run only the width of the shield rectangle.
         this.drawLine( ctx_tt, new cP.Vec2D(x1, y0a), new cP.Vec2D(x2, y0a), {'width_px':5, 'color':this.gridColor});
         this.drawLine( ctx_tt, new cP.Vec2D(x1, y0b), new cP.Vec2D(x2, y0b), {'width_px':5, 'color':this.gridColor});
         // The next pair do the main bottom line: second segment is at a higher y level for the scope rectangle.
         this.drawLine( ctx_tt, new cP.Vec2D(x0, y1),  new cP.Vec2D(x2, y1),  {'width_px':5, 'color':this.gridColor});
         this.drawLine( ctx_tt, new cP.Vec2D(x2, y0b), new cP.Vec2D(x3, y0b), {'width_px':5, 'color':this.gridColor});
         
         // Adjustment Point
         //this.drawCircle( ctx_tt, this.adjustmentPoint_2d, {'fillColor': 'red', 'radius_px':5} );
         
         // Status dots
         this.updateStatusDot('jet_360',   this.gridColor);
         this.updateStatusDot('gun_360',   this.gridColor);
         this.updateStatusDot('shield',    this.gridColor);
         this.updateStatusDot('alt',       this.gridColor);
         this.updateStatusDot('gun_scope', this.gridColor);
         
         // Control ring
         this.drawCircle( ctx_tt, this.grid['jet_360'].center_2d, 
            {'fillColor':'noFill', 'radius_px':this.grid['jet_360'].cRadius_1_px, 'borderWidth_px':3, 'borderColor':this.jetRadiusColor_1} );
         this.drawCircle( ctx_tt, this.grid['jet_360'].center_2d, 
            {'fillColor':'noFill', 'radius_px':this.grid['jet_360'].cRadius_2_px, 'borderWidth_px':3, 'borderColor':this.jetRadiusColor_2} );
         this.drawCircle( ctx_tt, this.grid['jet_360'].center_2d, 
            {'fillColor':'noFill', 'radius_px':this.grid['jet_360'].cRadius_3_px, 'borderWidth_px':3, 'borderColor':this.jetRadiusColor_3} );
         
         this.drawCircle( ctx_tt, this.grid['gun_360'].center_2d,
            {'fillColor':'noFill', 'radius_px':this.grid['gun_360'].cRadius_0_px, 'borderWidth_px':3, 'borderColor':this.gunRadiusColor_0} );
         
         /*
         // Draw some test lines for use in positioning text on the grid (1% by 1% grid cells).
         for (var i = 0; i < 100; i++) {
            // Vertical lines in 1% steps of x range.
            var x_test_px = x0 + this.absPos_x_px( i * 0.01);
            this.drawLine( ctx_tt, new cP.Vec2D(x_test_px, y0), new cP.Vec2D(x_test_px, y2), {'width_px':1, 'color':this.gridColor});
            
            // Horizontal lines in 1% steps of y range (remember y increased going down the screen)
            var y_test_px = y0 + this.absPos_y_px( i * 0.01);
            this.drawLine( ctx_tt, new cP.Vec2D(x0, y_test_px), new cP.Vec2D(x3, y_test_px), {'width_px':1, 'color':this.gridColor});
         }
         */
         
         // Text labels
         ctx_tt.font = this.scaledFont(25); //25px Arial
         ctx_tt.fillStyle = this.gridColor;
         
         // Set the location on this text using fractional positions. These x,y coordinates specify the position of the lower left corner of the text string.
         ctx_tt.fillText('jet',     this.grid['jet_360'].UL.x   + this.absPos_x_px(0.020),  this.grid['jet_360'].UL.y   + this.absPos_y_px( 0.06));
         ctx_tt.fillText('shooter', this.grid['gun_360'].UL.x   + this.absPos_x_px(0.020),  this.grid['gun_360'].UL.y   + this.absPos_y_px( 0.06));
         ctx_tt.fillText('scope',   this.grid['gun_scope'].UL.x + this.absPos_x_px(0.020),  this.grid['gun_scope'].UL.y - this.absPos_y_px( 0.03));
         
         ctx_tt.fillText('shield',  this.grid['shield'].UL.x    + this.absPos_x_px(0.022),  this.grid['shield'].UL.y    + this.absPos_y_px( 0.06));
         
         ctx_tt.font = this.scaledFont(20); //20px Arial
         ctx_tt.fillText('7',       this.grid['demo7'].UL.x     + this.absPos_x_px(0.005),  this.grid['demo7'].UL.y     + this.absPos_y_px( 0.05));
         ctx_tt.fillText('8',       this.grid['demo8'].UL.x     + this.absPos_x_px(0.005),  this.grid['demo8'].UL.y     + this.absPos_y_px( 0.05));
         ctx_tt.fillText('f',       this.grid['freeze'].UL.x    + this.absPos_x_px(0.005),  this.grid['freeze'].UL.y    + this.absPos_y_px( 0.05));
         
         ctx_tt.font = this.scaledFont(19); //19px Arial
         ctx_tt.fillText('esc',     this.grid['esc'].UL.x       + this.absPos_x_px(0.005),  this.grid['esc'].UL.y       + this.absPos_y_px( 0.05));
         ctx_tt.fillText('alt',     this.grid['alt'].UL.x       + this.absPos_x_px(0.005),  this.grid['alt'].UL.y       + this.absPos_y_px( 0.05));
         ctx_tt.fillText('ccw',     x2a                         - this.absPos_x_px(0.052),  this.grid['gun_scope'].UL.y + this.absPos_y_px( 0.070));
         ctx_tt.fillText('cw',      x2b                         + this.absPos_x_px(0.009),  this.grid['gun_scope'].UL.y + this.absPos_y_px( 0.070));
         
         if (cl.name) this.colorClientRect( clientColor( cl.name));
      }
   }
   // Function supporting full-screen display mode
   TwoThumbs.prototype.changeDisplay = function( mode) {
      if ((mode == 'fullScreen') || (mode == 'normal')) {
         
         if (window.innerWidth < window.innerHeight) {
            var orientationMessage = "The Two-Thumbs client requests that your phone be oriented for landscape viewing. Please turn it sideways, then try touching the Two-Thumbs button again."
            alert( orientationMessage);
            displayMessage( orientationMessage);
            return;
         }
         
         this.enabled = true;
         sendSocketControlMessage( {'from':cl.name, 'to':'host', 'data':{'twoThumbsEnabled':{'value':true}} } );
         
         // If there's a stream active, shut it down.
         if (chkRequestStream.checked) {
            chkRequestStream.click();
         }
         // Reveal the twoThumbs canvas.
         clientCanvas_tt.removeAttribute("hidden");
         // Hide the video streaming element.
         videoMirror.setAttribute("hidden", null);

         if (mode == 'fullScreen') {
            var dpr = window.devicePixelRatio;
            //console.log('dpr='+dpr);
            changeFullScreenMode( clientCanvas_tt, 'on');
            
            if (dpr <= 1.25) {
               var scalingFactor = 1.0; // my laptop
            } else {
               var scalingFactor = dpr * 0.6; // my moto
            }
            
            // A larger delay is needed with FireFox. Some delay is also needed with
            // Chrome on Android. Both seem to work fine at 600ms. So for now...
            var userAgent = window.navigator.userAgent;
            if (userAgent.includes("Firefox")) {
               var waitForFullScreen = 600;
               console.log("firefox detected");
            } else {
               var waitForFullScreen = 600;
            }
         
            // Delay (to wait for fullscreen change to finish) is needed.
            window.setTimeout(function() {
               clientCanvas_tt.width  = window.innerWidth  * scalingFactor;
               clientCanvas_tt.height = window.innerHeight * scalingFactor; 
               //ctx_tt.scale(dpr, dpr); // played with this, but not useful here...
            }, waitForFullScreen);
            
            // Wait until a little after the canvas-resize delay above.
            // Notice "this" context is passed in with bind.
            window.setTimeout(function() {
               this.updateAndDrawTouchGrid('draw');
               
               // Check with the host to see if there is a puck for this client. (Note, of course, that gW.clients of the hosts browser window is
               // not be accessible here in the client window.) This will also sync the angles of the jet and gun tubes on the client.
               sendSocketControlMessage( {'from':cl.name, 'to':'host', 'data':{'puckPopped':{'value':'probeAtHost'}} } );
               
            }.bind(this), 700);
            
         } else if (mode == 'normal') {
            this.updateAndDrawTouchGrid('draw');
         }
         
      } else if (mode == 'exit') {
         if (document.fullscreenElement) changeFullScreenMode( clientCanvas_tt, 'off');
         
         // De-activate the two rectangles that may have gotten you in here (remember, you didn't have to lift
         // your fingers). This effectively resets these rectangles (like releasing your touch).
         this.grid['esc'].active = false;
         this.grid['alt'].active = false;
         
         // Reveal the video element.
         if (clientDeviceType == "desktop") videoMirror.removeAttribute("hidden");
         // Hide the two thumbs canvas.
         clientCanvas_tt.setAttribute("hidden", null);
         
         chkTwoThumbs.checked = false;
         this.enabled = false;
         sendSocketControlMessage( {'from':cl.name, 'to':'host', 'data':{'twoThumbsEnabled':{'value':false}} } );
         initialize_mK(); // clear out the TT parameters like thrust, as you shift back to keyboard play.
      }
   }
   
   //////////////////////////////////////////////////   
   // Functions supporting the socket.io connections
   //////////////////////////////////////////////////   
   
   function disableClientControls( diableMode) {
      // diableMode: true (disable it) or false
      if (diableMode) {
         $('#ConnectButton').html('Wait');
         $('#ConnectButton').prop('disabled', true);
         
         $('#chkRequestStream').prop('disabled', true);
         
         $('#twoThumbsButton').prop('disabled', true);
         $('#ChatButton').prop('disabled', true);
         
      } else {
         // Change the label from 'Wait' to 'Connect'.
         $('#ConnectButton').html('Connect');
         $('#ConnectButton').prop('disabled', false);
         // Note: the streaming checkbox opens when the p2p data-channel opens (see cl.rtc.dataChannel.onopen).
         //       the two-thumbs button opens when the room is successfully joined.
         //       the chat button opens when the room is successfully joined.
      }
   }
   
   function clearInputDefault( a) {
      if (a.defaultValue == a.value) {
         a.value = "";
      }
   }
   function restoreInputDefault( a) {
      a.value = a.defaultValue;
   }
   
   function checkForNickName( mode, hostOrClient) {
      var nickName = {'status':'ok', 'value':null};

      // Check the chat input field, e.g. jimbo
      var chatString   = $('#inputField').val(); // the users entry
      var nnFieldValue = $('#nickNameField').val(); // input field in the ghost-ball pool help panel
      var defaultValue = $('#inputField').prop('defaultValue'); //this is the value attribute in the html
      if (mode =='normal') {
         // New nick name in the chat input field. (Recently removed the need for the "nn:" prefix.)
         //if (chatString.includes('nn:') || chatString.includes('Nn:') || chatString.includes('nn ') || chatString.includes('Nn ')) {
         if ((chatString != "") && (chatString != defaultValue)) { 
            nickName.value = chatString; // chatString.slice(3, chatString.length);
            
            if (nickName.value.length > 10) {
               nickName.status = "too long";
               return nickName;
               
            } else {
               if (hostOrClient == 'client') {
                  // Make the nickName accessible from the client. Remember, this cl object
                  // exists on the client.
                  cl.nickName = nickName.value;
               } else if (hostOrClient == 'host') {
                  gW.clients['local'].nickName = nickName.value;
               }
               // Clear out the input field where the nick name was entered.
               $('#inputField').val('');
               // Also sync the input field in the ghost-ball pool help panel.
               $('#nickNameField').val( nickName.value);
            }
         
         // Nothing new, so use the current nick name if it's there.   
         } else {
            if (hostOrClient == 'client') {
               nickName.value = cl.nickName;
               
            } else if (hostOrClient == 'host') {
               if (nnFieldValue != "") {
                  nickName.value = nnFieldValue;
                  gW.clients['local'].nickName = nickName.value;
               } else {
                  nickName.value = gW.clients['local'].nickName;
               }
            }
         }
         
      } else if ((mode == 're-connect') && cl.nickName) {
         nickName.value = cl.nickName;
      }
      return nickName;
   }
   
   function connect_and_listen( hostOrClient, mode) {
      if (mode == 'normal') {
         // Reset the counter when the connection is initiated from the button.
         connHist.connectListenCounts = 0;
         gb.touchScreenUsage_sendCounter = 0;
      } else if (mode == 're-connect') {
         // Add to the count so the number of retries can be limited.
         connHist.connectListenCounts += 1;
      } else if (mode == 're-connect-with-stream') {
         // Don't count the first re-connect associated with the video stream.
         mode = 're-connect';
         connHist.connectListenCounts = 0;
      }
      
      // Run some checks on the room name.
      var roomName = $('#roomName').val();
      // Gotta have something...
      if (roomName == "") {
         var buttonName = (hostOrClient == 'client') ? '"Connect"' : '"Create"';
         displayMessage('Type in a short "Room" name, then click the ' + buttonName + ' button.');
         document.getElementById("roomName").style.borderColor = "red";
         return;
      // the HTML limit is set to 9 (so you can try a little more then 7, but then get some advice to limit it to 7)
      } else if (roomName.length > 7) {
         displayMessage('The name should have 7 characters or less.');
         document.getElementById("roomName").style.borderColor = "red";
         return;
      }
      
      // Clear the default string (nickname tip) in the chat field.
      clearInputDefault( document.getElementById('inputField'));
            
      // Check to see if there's a nickname in the chat input field.
      var nickName = checkForNickName( mode, hostOrClient);
      if (nickName.status == 'too long') {
         displayMessage('Nicknames must 10 characters or less. Shorten the name and then try connecting again.');
         return;
      }
            
      if (hostOrClient == 'client') {
         // Disable some of the client controls to keep users from repeatedly
         // clicking the connect button.
         disableClientControls(true);
         refresh_P2P_indicator({'mode':'connecting'});
         
         // Open the connect button after 4 seconds. Sometimes there are network delays.
         // Note: most of the disabled controls open based on events. For example: the 
         // streaming checkbox opens when the p2p data-channel opens (see cl.rtc.dataChannel.onopen).
         window.setTimeout(function() {
            disableClientControls( false);
         }, 4000);
      } else {
         displayMessage('Connecting as host. Please wait up to 20 seconds...');
      }
   
      var nodeString = $('#nodeServer').val();
      if (nodeString == "") {
         // Use one in the list as a default.
         nodeString = serverArray[0];  // [0] or [2]
         $('#nodeServer').val( nodeString);
      }
      if (nodeString.includes("heroku")) {
         var urlPrefix = "https://"
      } else {
         var urlPrefix = "http://"
      }
      nodeServerURL = urlPrefix + nodeString;
      //console.log("URL=" + nodeServerURL);
            
      // Use jquery to load the socket.io client code.
      $.getScript( nodeServerURL + "/socket.io/socket.io.js", function() {
         
         // This callback function will run after the getScript finishes loading the socket.io client.
         console.log("socket.io script has loaded."); 
         
         // If there are already active network connections, close them before making new ones. This is 
         // the case if the client repeatedly clicks the connect button trying to get a preferred color.
         if (socket) {
            if (hostOrClient != 'host') {
               // Send a message to the host (via socket.io server) to shutdown RTC connections.
               if (newClientName) {
                  if (videoMirror.srcObject) videoMirror.srcObject = null;
                  // Trigger client shutdown at the host.
                  socket.emit('shutDown-p2p-deleteClient', newClientName);
               }
            }
            window.setTimeout( function() {
               // Close socket.io connection after waiting a bit for the p2p connections to close.
               socket.disconnect();
            }, 500);
         }
         
         // Delay this (connection to the server) even longer than the socket.disconnect() above (to be sure the disconnect is done).
         window.setTimeout( function() {
            // When starting a new normal connection, turn off the stream.
            if (mode =='normal' && (hostOrClient != 'host')) chkRequestStream.checked = false;
            
            // Here is where the socket.io client initiates it's connection to the server. The 'query' parameter
            // shows the form of the query string needed for a multi-parameter example. This is how you pass parameters
            // to the connection handler in server.js.
            if (nickName.value) {
               var nickNameString = '&nickName='+ nickName.value;
            } else {
               var nickNameString = '';
            }
            var queryString = 'mode=' + mode + '&currentName=' + cl.name + nickNameString;
            socket = io.connect( nodeServerURL, {'forceNew':true, 'query':queryString});
            
            init_socket_listeners( roomName, hostOrClient);
            
         }, 600);
         
         // Check for P2P on the client and try again (reconnect) if needed.
         if ((hostOrClient == 'client') && (connHist.connectListenCounts < 1)) {
            if (window.navigator.userAgent.includes("Firefox")) {
               var waitBeforeCheck = 5500; // Mozilla
            } else {
               var waitBeforeCheck = 3500; // Chrome
            }
            window.setTimeout( function() {
               console.log('checking for p2p connection');
               var p2pConnection = ( (!rtc_choke) && (cl.rtc) && (cl.rtc.dataChannel) && (cl.rtc.dataChannel.readyState == 'open') );
               if ( ! p2pConnection) {
                  connect_and_listen('client', 're-connect'); // Yes, connect_and_listen is this function.
               }
            }, waitBeforeCheck);
         }
         
      // Use the "fail" method of getScript to report a connection problem.  
      }).fail(function( jqxhr, settings, exception) {
         displayMessage('The node server is not responding. Try changing to a different server.');
         document.getElementById("roomName").style.borderColor = "red";
         if (hostOrClient == 'client') refresh_P2P_indicator({'mode':'reset'});
      });
   }
   
   function displayMessage( msgText) {
      if (msgText.includes("Game Summary")) {
         gb.gameReportCounter += 1;
         var idString = " id='gR" + gb.gameReportCounter + "'";
      } else {
         var idString = "";
      }
      
      // Every other line, toggle the background shading.
      if (chatStyleToggle) {
         var styleString = "style='background: #efefef;'";
      } else {
         var styleString = "style='background: #d9d9d9;'";
      }
      
      $("#messages").prepend("<li " + styleString + idString + ">"+ msgText +"</li>");
      
      // Remove any help links on the client (because only the host has the help div).
      if (clientDeviceType) $(".helpLinkFromLB").remove();
      
      chatStyleToggle = !chatStyleToggle;
   }

   // Used for broadcasting a message to non-host players.
   function chatToNonHostPlayers( msgTxt) {
      if (socket) socket.emit('chat message but not me', msgTxt + '</br>');
   }
   
   function init_chatFeatures( hostOrClient) {
      //secure-retreat-15768
      serverArray = ['timetocode.herokuapp.com',
                         'localhost:3000',
                         '192.168.1.106:3000',  
                         '192.168.1.109:3000',  //David's computer
                         '192.168.1.116:3000',  //RPi
                         '192.168.1.117:3000']; //Laptop
      // Use jquery to loop over the serverArray and build the URL datalist.
      jQuery.each( serverArray, function( i, val ) {
         $('#nodeServerList').append("<option value='" + val + "'>");
      });
   
      var pingTestHelp = "Your ping test has started.<br><br>" +
                         "Please wait about 10 seconds for the results of the 100-ping test to return. Each time you hit enter or click the chat button " +
                         "a new 100-ping test will be queued. Please manually clear out the words 'ping' or 'ping:host' to stop pinging and start chatting.";
   
      // Function that emits (if a socket has been established) the text in the form's input field.
      $('#chatForm').submit(function() {
         var chatString =   $('#inputField').val(); // user entry
         var defaultValue = $('#inputField').prop('defaultValue'); //this is the value attribute in the html
         if (socket) {
            if (chatString == 'ping') {
               echoTest('server');
               displayMessage( pingTestHelp);
            } else if (chatString == 'ping:host') {
               echoTest('host');
               displayMessage( pingTestHelp);
            } else {
               if (chatString != defaultValue) {
                  // All is well, chat out the message.
                  socket.emit('chat message', chatString);
               } else {
                  displayMessage('Nickname tip has been cleared from the chat field. Ready to chat now.<br><br>' +
                                 'Note: an alternative way for the host to establish a nickname is to put it in the chat field before starting demos 6, 7, or 8.');
               }
               $('#inputField').val(''); //clear out the input field.
            }
         } else {
            // Note that I've grayed out the unconnected (no socket) chat button on both the client and host pages so
            // this little block will not run anymore.
            var buttonName = (hostOrClient == 'client') ? '"Connect"' : '"Create"';
            displayMessage('Type in a short "Room" name, then click the ' + buttonName + ' button.');
         }
         return false;
      });
      
      // Prevent typing in the input fields from triggering document level keyboard events.
      $('#inputField, #nodeServer, #roomName, #jsonCapture').on('keyup keydown keypress', function( e) {
         e.stopPropagation(); // stops bubbling...
      });
      
      // A first message in the chat area
      var helloMessage, helloMessageA;
      
      if (hostOrClient == 'host') {
         helloMessage = '' +
         'From this page you can host a multiplayer room.</br></br>'+
         
         'To get started, type a short room name into the red box, then click the "Create" button.</br></br>'+
         
         'Optionally, provide a nickname in the chat field before clicking the "Create" button or starting a game.</br></br>' +
         
         'Use the "m" key (or click the "Multiplayer" checkbox) to toggle between this chat panel (where leaderboard reports are presented) and the ' + "<a onclick= \" $('#chkMultiplayer').trigger('click'); \">help</a> " + 
         'panel. Doing so will not disable connections.</br></br>'+
         
         'Please notice the links to the <a href="client.html" target="_blank">client page</a>, in the right panel, below the "Friendly fire" checkbox. '+
         'You can also get to the client page from the menu in the upper-left corner.</br></br>'+
         
         'When setting up the room as host, you might not get an immediate response from the server. It can take a little while for the Heroku node application to wake up. '+
         'If waking, give it 10 to 20 seconds before expecting a confimation message in this chat area.</br></br>'+
         
         'To start over, or disconnect from the server, please reload the page.';
         
      } else {
         if (clientDeviceType == 'mobile') {
            helloMessageA = 'This is the mobile client page for multiplayer. This leads to the Two Thumbs interface. '+
                            "(Note that there are additional options with the <a href='client.html'>desktop</a> client.) </br></br>";
            window.resizeTo(475,750);   // 475,750
         } else {
            helloMessageA = 'This is the desktop client page for multiplayer. '+
                            "(Note that the <a href='client.html?m'>mobile</a> version of this client page offers the most straightforward path to the Two Thumbs interface.) </br></br>";
            window.resizeTo(1240,750);  // 1240,750
         }
         
         helloMessage = helloMessageA +
         
         'From here you can be a client in a multiplayer room. The room must be started (hosted) from the main www.timetocode.org page. '+
         'Generally, a separate computer is used for hosting. For testing, the host and multiple clients can be run in separate windows on the same computer.</br></br>'+
         
         'To connect as a client, enter the room name (provided to you by the host), into the red box here, then click the "Connect" button. '+ 
         'Optionally, provide a nickname in the chat field before clicking the "Connect" button.</br></br>' +
         
         'To start over, or disconnect from the server, please reload the page.';
      }
      displayMessage( helloMessage);
   }
   
   function clientColor( clientName) {
      var colors = cP.Client.colors;
      var n = clientName.slice(1);
      var colorIndex = n - Math.trunc(n/10)*10;
      return colors[ colorIndex];
   }

   function init_socket_listeners( roomName, hostOrClient) {
      
      // Listeners needed by both the client and the host.
      
      // Listen for chat being forwarded by the server.
      socket.on('chat message', function(msg) {
         displayMessage( msg);
      });
      
      // Change the border color of the roomName input box depending on the 
      // message from the node server. And add additional info to the message.
      socket.on('room-joining-message', function(msg) {
         if (msg.includes('You have joined room')) {
            // Some visual indicators that the connection succeeded.
            document.getElementById("roomName").style.borderColor = "#008080"; //Dark green.
            $('#ChatButton').prop('disabled', false);
            
            // If the names are the same, this indicates the network client has rejoined with a video stream.
            if (hostOrClient == 'client') {
               $('#twoThumbsButton').prop('disabled', false);
            
               // If the name is the same, that indicates a reconnection. Either the client has
               // requested a video stream or is making a second attempt at a P2P connection.
               if (cl.name == cl.previous_name) {
                  if (cl.nickName) {
                     var nNstring = ' ('  + cl.nickName + ').';
                  } else {
                     var nNstring = '.';
                  }
                  var nameString = cl.name + nNstring;
                  
                  // Adjust the reconnection message for P2P and streaming attempts. Wait for the connection process to finish.
                  if (window.navigator.userAgent.includes("Firefox")) {
                     var waitBeforeCheck = 4500; // Mozilla
                  } else {
                     var waitBeforeCheck = 2000; // Chrome
                  }
                  window.setTimeout( function() {
                     //console.log("rtc state:" + JSON.stringify(cl.rtc) );
                     var p2pConnection = ( (!rtc_choke) && (cl.rtc) && (cl.rtc.dataChannel) && (cl.rtc.dataChannel.readyState == 'open') );
                     if (p2pConnection) {
                        if (chkRequestStream.checked) { 
                           msg = 'You have reconnected with a video stream. Your name is still ' + nameString;
                        } else {
                           msg = 'A P2P connection has been established. Your name is still ' + nameString;
                        }
                     // P2P attempt failed
                     } else {
                        if (chkRequestStream.checked) { 
                           msg = 'You attempted to reconnected with a video stream. However, the needed P2P connection could not be established. Your name is still ' + nameString;
                        } else {
                           msg = 'An attempt to upgrade your connection from socket.io to peer-to-peer (P2P) has not succeeded. Your name is still ' + nameString + ' ' +
                                 'You may wish to simply try the "Connect" button again... ' + 
                                 '<br><br>' +
                                 'All the demos work well with a socket.io connection, but there is a little more lag and no streaming option. ' +
                                 '<br><br>' +
                                 'Difficulty establishing a P2P connection can be related to your browser or network conditions. ' +
                                 'As an alternative, you may wish to try running your own local node server as described in the "Installation of a node server" section ' +
                                 'on the <a href="multiplayer.html" target="_blank">Multiplayer</a> page.';
                        }
                     }
                     displayMessage( msg);
                  }, waitBeforeCheck);
               
               // Normal non-host client connection (not a reconnect).
               } else {
                  if (clientDeviceType == 'mobile') {
                     // Let the host know this (pure Two-Thumbs) so the client cursor can be inhibited.
                     var control_message = {'from':cl.name, 'to':'host', 'data':{'clientDeviceType':'mobile'} };
                     socket.emit('control message', JSON.stringify( control_message));
                     
                     msg += ''+
                     "</br></br>"+
                                       
                     "Touch the <strong>Two Thumbs</strong> button to start the virtual game-pad interface. This requires line-of-sight to the host's monitor. "+
                     "If you don't have line-of-sight, you can start up a second client (in desktop mode) and stream to it.</br>";
                  } else {
                     msg += ''+
                     "</br></br>"+
                     "You are in <strong>normal desktop</strong> mode. Your mouse and keyboard events get sent to the host. You must have direct visual access to the host's monitor."+
                     "</br></br>"+
                     
                     "Two other options:</br></br>"+
                     
                     "<strong>Stream:</strong> This is like normal mode, but the host's canvas is rendered in the video element here. "+
                     "So you can play out-of-sight of the host's monitor, in a separate room, city, country...</br></br>"+
                     
                     "<strong>Two Thumbs:</strong> touch-screen interface for your phone. Similar to normal mode, this requires line-of-sight to the host's monitor. "+
                     "However, you can start up a second client (on a second device) and stream to it if you don't have line-of-sight.</br>";
                  }
               } 
            }
         
         // Client might get this warning...
         } else if (msg.includes('Sorry, there is no host')) {
            document.getElementById("roomName").style.borderColor = "red";
            refresh_P2P_indicator({'mode':'reset'});
         
         // A candidate host might get this warning... 
         } else if (msg.includes('Sorry, there is already a host')) {
            document.getElementById("roomName").style.borderColor = "red";
         
         // Additional instructions for the new host. This room-joining-message event will have to be triggered a second time to get this message to the host after
         // the "You have joined room" message above.
         } else if (msg.includes('You are the host')) {
            var openWindowString = '"' + "window.open('client.html', '_blank', 'width=1240, height=750') " + '"';
            msg += ' Your name is ' + gW.clients['local'].nameString() + '. ' +
            "</br></br>"+
            'You can still establish or change the nickname for the host. Put it in the chat field '+
            'before running the game demos 6, 7 or 8 (do not submit as chat). This nickname is used in reports to the leaderboard.'+
            '</br></br>'+
            "You can open a test <a href='#' onClick=" + openWindowString + "title='Open a client page in a new window.'>client</a> in a new window. "+
            "Connect the client using the same room name you established here as the host. Then the client mouse and keyboard events will be transmitted to the canvas of the host.";
         }
         displayMessage( msg);
      });
      
      // Once your connection succeeds, join a room.
      socket.on('connect', function() {
         
         if (hostOrClient == 'host') {
            socket.emit('roomJoin', JSON.stringify({'hostOrClient':hostOrClient,'roomName':roomName}));
            
         } else if (hostOrClient == 'client') {
            socket.emit('roomJoin', JSON.stringify({'hostOrClient':hostOrClient,'roomName':roomName,
                                                    'player':chkPlayer.checked,
                                                    'requestStream':chkRequestStream.checked}));
         }
      });
      
      // Listen for echo response from the server.
      socket.on('echo-from-Server-to-Client', function( msg) {
         var echoTarget = msg;
         
         // Stop timer (measure the round trip).
         timer.stop = window.performance.now();
         var elapsed_time = timer.stop - timer.start;
         // Add this new timing result to the array.
         timer.pingArray.push( elapsed_time);
         
         // The echo series STOPs here.
         if (timer.pingArray.length > 99) {
            var timeAvg = Math.mean( timer.pingArray).toFixed(1);
            var timeSTD = Math.std( timer.pingArray).toFixed(1);
            var timeLen = timer.pingArray.length;
            var timeMax = Math.max( timer.pingArray).toFixed(1);
            var timeMin = Math.min( timer.pingArray).toFixed(1);
            displayMessage('Echo test to '+ echoTarget +': '+ timeAvg +' ms '+
                           '(std='+  timeSTD +
                           ', min='+ timeMin +
                           ', max='+ timeMax +
                           ', n='+   timeLen +')');
            timer.pingArray = [];
            return;
         }
         
         // Ping it again (continue the series).
         echoTest( echoTarget);
         
         // Do this after the timer starts (don't slow it down with a write to the console.)
         console.log( echoTarget);
      });
      
      // WebRTC Signaling.
      // This handles signaling from both sides of the peer-to-peer connection.
      socket.on('signaling message', function(msg) {
         // Convert it back to a usable object (parse it).
         var signal_message = JSON.parse(msg);
         
         // Note that signalData needs to be in a stringified form when writing to the console.
         debug( db.rtc,"signal message from " + signal_message.from + ", to " + signal_message.to + ": " + JSON.stringify(signal_message.signalData));
         
         // Offers and Answers
         if (signal_message.signalData.sdp) {
            debug( db.rtc,'sdp in signal from host: ' + JSON.stringify(signal_message.signalData));
            
            if (signal_message.signalData.type == 'offer') {
               debug( db.rtc,"an offer");
               handleOffer( signal_message.signalData);
               
            } else if (signal_message.signalData.type == 'answer') {
               debug( db.rtc,"an answer");
               handleAnswer( signal_message.signalData);
               
            } else {
               console.log("Woooooo-HoHo-Hoooooo, something is screwed up. This can't be good.");
            }
         
         // ICE candidates
         } else if (signal_message.signalData.candidate) {
            
            // handle ICE stuff.
            cl.rtc.pc.addIceCandidate( signal_message.signalData)
            .catch( function( reason) {
               // An error occurred, so...
               console.log('Error while handling ICE stuff:' + reason);
            });
            
            debug( db.rtc,'signaling state after handling ICE = ' + cl.rtc.pc.signalingState);
            
         } else {
            //No WebRTC stuff found in the signaling message. Maybe you are testing...
            console.log("No WebRTC stuff found in the signaling message. This is the final else block of 'signaling message' handler.");
         }
         
      });
      
      socket.on('control message', function( msg) {
         // General receiver of control messages. This can be used by either the host or a client to
         // receive messages from anyone.
         // (Refer to the handler for command-from-host-to-all-clients for a similar but more specific approach to
         // command processing.)
         
         // Convert the raw msg back to a usable object (parse it).
         var message = JSON.parse( msg);
         
         // Control message directed to the host.
         // Note the use of gW.clients here. This is only available on the host device.
         if (message.to == 'host') {
            if (message.data['videoStream'] == 'off') {
               gW.clients[ message.from].rtc.turnVideoStreamOff();
               
            } else if (message.data['fullScreen'] == 'off') {
               console.log('full screen requested off by client');
               // I played around with trying to do something here, but the browsers fullscreen API requires that
               // a change starts with a gesture. The error: '...API can only be initiated by a user gesture.'
               
            } else if (message.data['clientDeviceType'] == 'mobile') {
               // Attribute to inhibit client cursor.
               gW.clients[ message.from].deviceType = 'mobile';
               console.log('client ' + message.from + ' is in mobile mode');
               
            } else if (message.data['puckPopped']) {
               if (message.data['puckPopped'].value == 'probeAtHost') {
                  // Check to see if the requesting client is still in the clients object and still has a puck.
                  if ((message.from in gW.clients) && (gW.clients[ message.from].puck)) {
                     var puckPopped = false;
                  } else {
                     var puckPopped = true;
                  }
                  // Send reply message back to the client.
                  var control_message = {'from':'host', 'to':message.from, 'data':{'puckPopped':{'value':puckPopped}} };
                  sendSocketControlMessage( control_message);
                  
                  // Sync the gun and jet angles, i.e. send angles out to the clients.
                  if (gW.clients[ message.from]) gW.clients[ message.from].gunAngleFromHost(0, true);
                  if (gW.clients[ message.from]) gW.clients[ message.from].jetAngleFromHost();
               }
               
            } else if (message.data['twoThumbsEnabled']) {
               if (gW.clients[ message.from]) gW.clients[ message.from].twoThumbsEnabled = message.data['twoThumbsEnabled'].value;
            
            } else if (message.data['touchScreenUsage']) {
               if (gW.clients[ message.from]) gW.clients[ message.from].touchScreenUsage = message.data['touchScreenUsage'].value;
            }
            
         // Control message directed to a non-host client.
         } else {
            if (message.data['canvasResize']) {
               console.log("command to resize canvas: " + message.data['canvasResize'].width + ", " + message.data['canvasResize'].height);
               videoMirror.width = message.data.canvasResize.width; 
               videoMirror.height = message.data.canvasResize.height;
               
            } else if (message.data['gunAngle']) {
               //console.log("message.to=" + message.to + ", gunAngle=" + message.data['gunAngle']);
               twoThumbs.processGunAngleFromHost( message.data);
               
            } else if (message.data['jetAngle']) {
               twoThumbs.processJetAngleFromHost( message.data);
               
            } else if (message.data['puckPopped']) {
               twoThumbs.puckPopped = message.data['puckPopped'].value;
               // Indicate this info by updating the client-color rectangle on the TwoThumbs interface.
               twoThumbs.colorClientRect( clientColor( message.to));
            }
         }
      });
      
      // Listeners needed by the client only.

      if (hostOrClient == 'client') {  
         socket.on('your name is', function( msg) {
            var message = JSON.parse( msg);
            
            var name = setDefault( message.name, null);
            // Note: not (yet) doing anything with nickName that comes back from the socket.io server.
            // cl.nickName gets set for the client on the front end of the connection. Just including it
            // here for completeness.
            var nickName = setDefault( message.nickName, null);
            
            // Put this name in the mouse and keyboard (mK) global that is used to send
            // state data from the client.
            mK.name = name;
            
            // Put your name in this global (on the client side) for (possible) use by the WebRTC functions.
            newClientName = name;
            
            // Before updating cl.name with the new client name, store it's current value in previous_name.
            cl.previous_name = cl.name;
            cl.name = newClientName;
            
            debug( db.rtc,'names: current='+cl.name+ ', previous='+ cl.previous_name +', nick='+nickName);
            
            // Initialize this global container for the WebRTC stuff.
            cl.rtc = new RTC({'user1':newClientName,'user2':'host'});
         });
         
         socket.on('disconnectByServer', function(msg) {
            debug( db.rtc,'in client disconnectByServer, msg=' + msg);
            
            var clientName = msg;
            displayMessage("This client (" + clientName + ") is being disconnected by the host.");
            document.getElementById("roomName").style.borderColor = "red";
            
            // When the server gets this one, it will remove the socket.
            socket.emit('okDisconnectMe', clientName);
            
            // Shutdown and delete the client side of the WebRTC p2p connection.
            cl.rtc.shutdown();
            initialize_mK();
            //mK = {};
            
            // Delay this so it takes effect after the p2p toggle finishes.
            window.setTimeout( function() {
               displayMessage("");
               displayMessage("Shutdown of the connection for " + clientName + " has finished.");
               displayMessage("");
               displayMessage("");
               displayMessage("");
            }, 100);
         });
         
         // Refer to the "control message" handler for a more general approach to command processing. Below is a
         // specific host-to-all-clients approach.
         socket.on('command-from-host-to-all-clients', function( msg) {
            // Clients (only) do something based on the message from the host.
            var command_message = JSON.parse( msg);
            var type = command_message.type;
            var command = command_message.command;
            
            if (type == 'resize') {
               if (clientDeviceType == 'mobile') command = 'mobile';
               gW.adjustSizeOfChatDiv( command);
               
               if (command == 'normal') {
                  videoMirror.width = 600, videoMirror.height = 600;
               } else {
                  videoMirror.width = 1250, videoMirror.height = 950;
               }
            } else {
               console.log("no match in command-from-host-to-all-clients handler");
            }
         });
      }
      
      
      // Listeners needed by the host only.
      
      if (hostOrClient == 'host') {
         // (Note: this is the one place where calls to gW are made inside of hC.)
         
         // Listen for client mouse and keyboard (mk) events broadcast from the server.
         // StH: Server to Host
         socket.on('client-mK-StH-event', function(msg) {
            var msg_parsed = JSON.parse( msg);
            //console.log('State('+ msg_parsed.name +'):'+ msg_parsed.MD +','+ msg_parsed.bu +'): '+ msg_parsed.mX + "," + msg_parsed.mY);
            // Send this mouse-and-keyboard state to the engine.
            gW.updateClientState( msg_parsed.name, msg_parsed);
         });
         
         // As host, create a new client in gW framework.
         socket.on('new-game-client', function(msg) {
            var msgParsed = JSON.parse(msg);
            
            var streamRequested = msgParsed.requestStream;
            
            var clientName = msgParsed.clientName;
            var player = msgParsed.player;
            var nickName = msgParsed.nickName;
            
            gW.createNetworkClient({'clientName':clientName, 'player':player, 'nickName':nickName});
            
            // WebRTC. Start the p2p connection here (from the host) when we hear (from the server)
            // that a client is trying to connect to a room.
            // Make a global reference to this new (the most recent) client's RTC object.
            cl = gW.clients[clientName];
            cl.rtc.user1 = 'host';
            cl.rtc.user2 = clientName;
            cl.rtc.streamRequested = streamRequested;
            debug( db.rtc,'in new-game-client, cl.rtc.user2 = ' + cl.rtc.user2);
            
            // Start the WebRTC signaling exchange with the new client.
            // Diagnostic tools: chrome://webrtc-internals (in Chrome) and about:webrtc (in Firefox)
            try {
               openDataChannel( true); // open as the initiator
               createOffer();
            } catch(e) {
               console.log("WebRTC startup: " + e);
            }
            
            // Someone just connected. Send the host's layout state to them (actually to everyone, but that
            // should, of course, cover the connecting user also). Delay it a bit...
            window.setTimeout( function() {
               // Adjust client chat panel and canvas to match host (normal or small chat).
               resizeClients( gW.getChatLayoutState());
               // Adjust client canvas to match specific custom dimensions of the host's canvas.
               gW.setClientCanvasToMatchHost();
            }, 300);
         });
         
         socket.on('client-disconnected', function(msg) {
            var clientName = msg;   
            debug( db.rtc,'in client-disconnected, clientName=' + clientName);
            
            // Null out any WebRTC references in c object (most recent connection on the host page) if it happens to be
            // this client.
            nullReferences_toRTC_on_c( clientName);
            
            // Do corresponding cleanup in gwModule.
            gW.deleteNetworkClient( clientName);
         });
         
         socket.on('echo-from-Server-to-Host', function(msg) {
            // Bounce this back to server.
            // The msg string is the client id.
            socket.emit('echo-from-Host-to-Server', msg);
         });
         
         socket.on('shutDown-p2p-deleteClient', function( msg) {
            debug( db.rtc,'in shutDown-p2p-deleteClient');
            var clientName = msg;
            // First check for the case where the host has reloaded their page and 
            // then a client attempts to reconnect. In that case the clients map will be empty and
            // this clientName won't be found in there.
            if (gW.clients[ clientName]) {
               // Check for a puck controlled by this client. Delete it first.
               if (gW.clients[ clientName].puck) gW.clients[ clientName].puck.deleteThisOne({});
               // Then start shutting down the WebRTC connection.
               gW.deleteRTC_onClientAndHost( clientName);
            }
         });
      }
   } // end of init_socket_listeners
   
   
   // The following three functions are exposed for external use and are called from within gwModule.js.
   function forceClientDisconnect( clientName) {
      debug( db.rtc,'in forceClientDisconnect');
      socket.emit('clientDisconnectByHost', clientName);
   }
   function resizeClients( command) {
      if (socket) {
         socket.emit('command-from-host-to-all-clients', JSON.stringify({'type':'resize', 'command':command}));
      }
   }
   function sendSocketControlMessage( message) {
      // This is received and distributed at the server in its 'control message' handler. Then
      // received and processed at the host or client in their 'control message' handler.
      if (socket) {
         socket.emit('control message', JSON.stringify( message));
      }
   }
   
   function echoTest( hostOrServer) {      
      // Start the timer for one echo.
      timer.start = window.performance.now();
      
      // The echo series STARTs here.
      socket.emit('echo-from-Client-to-Server', hostOrServer);
   }
   
   
   
   ////////////////////////////////////////////////
   // Functions supporting the WebRTC connections.
   ////////////////////////////////////////////////
   
   var configuration = { 'iceServers': [{'urls': 'stun:stun1.l.google.com:19302'}] };
   
   function openDataChannel( isInitiator) {
      cl.rtc.pc = new RTCPeerConnection( configuration);
      
      // send any ice candidates to the other peer
      cl.rtc.pc.onicecandidate = function (evt) {
         if (evt.candidate) {
            var signal_message = {'from':cl.rtc.user1, 'to':cl.rtc.user2, 'signalData':evt.candidate};
            socket.emit('signaling message', JSON.stringify( signal_message));
         }
      };
      
      // Host-side data channel
      if (isInitiator) {
         debug( db.rtc,'host is setting up datachannel...');
         var dc_id = cl.rtc.user2.slice(1);
         var dc_options = {'id':dc_id, 'ordered':false, 'maxRetransmits':1};
         var dc_label = "dc-" + cl.rtc.user2;
         cl.rtc.dataChannel = cl.rtc.pc.createDataChannel(dc_label, dc_options);
         
         cl.rtc.dataChannel.onmessage = function( e) {
            handle_RTC_message( e);
         };
         cl.rtc.dataChannel.onopen    = function( ) {console.log("------ RTC DC(H) OPENED ------");};
         cl.rtc.dataChannel.onclose   = function( ) {console.log("------ RTC DC(H) closed ------");};
         cl.rtc.dataChannel.onerror   = function(e) {
            if (e.error != "OperationError: Transport channel closed") console.log("RTC DC(H) error: " + e.error);
         };
         
         if (cl.rtc.streamRequested) {
            startVideoStream();
         }
      
      // Client-side data channel
      } else {
         // This side of the data channel gets established in response to the channel initialization 
         // on the host side.
         cl.rtc.pc.ondatachannel = function(evt) {
            debug( db.rtc,'client response to ondatachannel...');
            cl.rtc.dataChannel = evt.channel;
            
            // Must set up an onmessage handler for the clients too.
            cl.rtc.dataChannel.onmessage = function(e) {
               debug( db.rtc,"DC (@client) message:" + e.data);
               
               // if we get something from the host.
               var objFromHost = JSON.parse( e.data);
               twoThumbs.processGunAngleFromHost( objFromHost.data);
            };
            cl.rtc.dataChannel.onopen = function() {
               console.log("------ RTC DC(C) OPENED ------");
               rtc_choke = false;
               $('#chkRequestStream').prop('disabled', false);
               refresh_P2P_indicator({'mode':'p2p', 'context':'dataChannelOpen'});
            };
            cl.rtc.dataChannel.onclose = function() {
               console.log("------ RTC DC(C) closed ------");
               rtc_choke = true;
            };
            cl.rtc.dataChannel.onerror = function(e) {
               if (e.error != "OperationError: Transport channel closed") console.log("RTC DC(C) error: " + e.error);
            };
         }
         
         // Respond to a new track by sending the stream to the video element.
         cl.rtc.pc.ontrack = function (evt) {
            videoMirror.srcObject = evt.streams[0];
         };
      }
      debug( db.rtc,'signaling state after openDataChannel = ' + cl.rtc.pc.signalingState);
   }
   
   // This function is used (only) by the host when someone connects and wants a stream.
   function startVideoStream() {
      if (!videoStream) {
         var hostCanvas = document.getElementById('hostCanvas');
         videoStream = hostCanvas.captureStream(); //60
      }
      cl.rtc.pc.addTrack( videoStream.getVideoTracks()[0], videoStream);
      // The chkStream is on the host page only (index.html)
      document.getElementById("chkStream").checked = true;
      videoStream.getVideoTracks()[0].enabled = true;
   }
   
   function setCanvasStream( newState) {
      if (videoStream) {
         if (newState == 'on') {
            videoStream.getVideoTracks()[0].enabled = true;
         } else {
            videoStream.getVideoTracks()[0].enabled = false;
         }
      }
   }
   
   function handle_RTC_message( msg) {
      //var user2 = Object.assign({}, cl.rtc.user2);
      
      /*
      var user2 = JSON.stringify(cl.rtc.user2);
      console.log("I am (cl.rtc.user2) = " + user2);
      console.log("DC ID = " + JSON.stringify(cl.rtc.dataChannel.id));
      console.log("DC (@host) message: " + e.data);
      */
      
      // Process mK events from the client on the other end of this peer-to-peer connection.
      var mK_string = msg.data;
      var mK_data = JSON.parse( mK_string);
      // Send this mouse-and-keyboard state to the engine.
      gW.updateClientState( mK_data.name, mK_data);
   }
   
   function createOffer() {
      cl.rtc.pc.createOffer()
      .then( function( offer) {
         return cl.rtc.pc.setLocalDescription( offer);
      })
      .then( function() {
         var signal_message = {'from':cl.rtc.user1, 'to':cl.rtc.user2, 'signalData':cl.rtc.pc.localDescription};
         socket.emit('signaling message', JSON.stringify( signal_message));
      })
      .catch( function(reason) {
         // An error occurred, so handle the failure to connect
         console.log('Error while creating offer:' + reason);
      });
      //console.log('signaling state after createOffer = ' + cl.rtc.pc.signalingState);
   }
   
   function handleOffer( msg) {
      openDataChannel( false); // Open as NOT the initiator
      
      cl.rtc.pc.setRemoteDescription( msg)
      .then( function() {
         return cl.rtc.pc.createAnswer( );
      })
      .then( function( answer) {
         return cl.rtc.pc.setLocalDescription( answer);
      })
      .then( function() {
         // Send the answer (localDescription) to the remote peer
         var signal_message = {'from':cl.rtc.user1, 'to':cl.rtc.user2, 'signalData':cl.rtc.pc.localDescription};
         socket.emit('signaling message', JSON.stringify( signal_message));
      })
      .catch( function( reason) {
         console.log('Error while handling offer:' + reason);
      });
      debug( db.rtc,'signaling state after handleOffer = ' + cl.rtc.pc.signalingState);
   }
   
   function handleAnswer( answer) {
      cl.rtc.pc.setRemoteDescription( answer)
      .catch( function( reason) {
         console.log('Error while handling answer:' + reason);
      });
      debug( db.rtc,'signaling state after handleAnswer = ' + cl.rtc.pc.signalingState);
   }

   function logError( error) {
      console.log( error.name + ': ' + error.message);
   }   
   
   function nullReferences_toRTC_on_c( clientName) {
      // Check the global "c" pointer (to the most recently connected client) to see if it happens to
      // be pointed at this client.
      //console.log('cl.rtc='+JSON.stringify( cl.rtc) + ", newClientName=" + clientName);
      if (cl.rtc && (cl.rtc.user2 == clientName)) {
         cl.rtc = new RTC({});
      }
   }

   function refresh_P2P_indicator( pars) {
      var mode = setDefault( pars.mode, 'p2p');
      var context = setDefault( pars.context, null);
      
      // Stop the pacifier (note: pacifier is a global object)
      // clearInterval() method clears a timer set with the setInterval() method
      clearInterval( pacifier.intFunction);
      
      // If connected, there will be a name (assigned from the server)
      if ((mode == 'p2p') && cl.name) {
         // Show (flood/erase the canvas with) the client's color.
         ctx.fillStyle = clientColor( cl.name);
         ctx.fillRect(0, 0, clientCanvas.width, clientCanvas.height);
         
         ctx.font = "12px Arial";
         // Use dark letters for the lighter client colors.
         var lightColors = cP.Client.lightColors; // an array
         if (lightColors.includes( clientColor( cl.name))) {
            ctx.fillStyle = 'black';
         } else {
            ctx.fillStyle = 'white';
         }
         
         // If the rtc choke is off and there's a data channel, display the "P2P" text.
         if (!rtc_choke && cl.rtc && cl.rtc.dataChannel && (cl.rtc.dataChannel.readyState == 'open')) {
            ctx.fillText('P2P', 10, 12);
         } else {
            ctx.fillText('socket.io', 10, 12);
         }
         
      } else if (mode == 'connecting') {
         ctx.fillStyle = 'darkgray';
         ctx.fillRect(0, 0, clientCanvas.width, clientCanvas.height);
         
         ctx.font = "12px Arial";
         ctx.fillStyle = 'white';
         ctx.fillText('CONNECTING', 10, 12);
         
         // Start the pacifier
         pacifier.string = '';
         pacifier.intFunction = setInterval( function() { 
            pacifier.string += '--';
            ctx.fillText( pacifier.string, 95, 12);
         }, 200);
         
      } else if (mode == 'reset') {
         // Light gray fill.
         ctx.fillStyle = '#EFEFEF';
         ctx.fillRect(0, 0, clientCanvas.width, clientCanvas.height);
      }
   }
   
   ////////////////////////////////////////////////////////////////////////////////
   // Functions supporting canvas animation
   ////////////////////////////////////////////////////////////////////////////////
   
   // Currently not using this steady animation loop approach. Instead, update the canvas 
   // as input events get fired. Refer to the methods in the TwoThumbs class.
   
   /*
   
   function canvasLoop( timeStamp_ms) {
      updateCanvas();
      
      myRequest = window.requestAnimationFrame( canvasLoop);
   }
   
   function updateCanvas() {
      // Clear the canvas (from one corner to the other)
      if (ctx_tt.globalCompositeOperation == 'screen') {
         ctx_tt.clearRect(0,0, clientCanvas_tt.width, clientCanvas_tt.height);
      } else {
         ctx_tt.fillStyle = 'blue';
         ctx_tt.fillRect(clientCanvas_tt.width/8, clientCanvas_tt.width/8, clientCanvas_tt.width/4, clientCanvas_tt.height/4);
      }
      
      if (twoThumbs.enabled) {
         // Draw the two-thumb state
         if (cl.name) {
            var circleColor = clientColor( cl.name);
         } else {
            var circleColor = 'white';
         }
         this.drawCircle( ctx_tt, {'x':mK.mX, 'y':mK.mY}, {'fillColor': circleColor} );
      }
   }
   
   function startAnimation() {
      // Only start a game loop if there is no game loop running.
      if (myRequest === null) {
         // Start the canvas loop.
         myRequest = window.requestAnimationFrame( canvasLoop);
      }
   }
   
   function stopAnimation() {
      window.cancelAnimationFrame( myRequest);
      myRequest = null;
   }
   
   */
   
   
   ////////////////////////////////////////////////////////////////////////////////
   // Misc functions
   ////////////////////////////////////////////////////////////////////////////////
   
   function init_nonHostClients() {
      
      // Get the URL query string. Discard everything after the "&".
      var queryStringInURL = window.location.search.split("&")[0];
      // Take everything after the ? and set a module (hC) level global, clientDeviceType, that will
      // be used in restricting features for a simplified mobile version of the client page.
      var queryStringValue = queryStringInURL.slice(1);
      if (queryStringValue == "m") {
         clientDeviceType = "mobile";
      } else {
         clientDeviceType = "desktop";
      }
      
      if (clientDeviceType == 'mobile') {
         gW.adjustSizeOfChatDiv('mobile');
      } else {
         gW.adjustSizeOfChatDiv('normal');          
      }
      
      init_eventListeners_nonHostClients();
      init_chatFeatures('client');
      twoThumbs = new TwoThumbs({});
      
      if (clientDeviceType == 'mobile') {
         // Move this button
         $('#divForTwoThumbsMobile').append( $('#twoThumbsButton') );
         
         // Hide the video streaming element.
         videoMirror.setAttribute("hidden", null);
         
         // Hide controls
         $("#nodeServerDiv").hide();  
         $("#playerAndCursor").hide();  
         $("#streamAndFullscreen").hide();  
         $("#twoThumbsButtonDiv").hide();

         // page title
         document.title = "S&P mobile client";
      }
      
      // Hide the div that covers the mess (while elements are moving)
      $("#blankWhiteDiv").hide();
   }
   
   function openFullscreen( elem) {
      if (elem.requestFullscreen) {
         console.log("fullscreen - normal");
         elem.requestFullscreen();
      } else if (elem.mozRequestFullScreen) { /* Firefox */
         console.log("fullscreen - moz");
         elem.mozRequestFullScreen();
      } else if (elem.webkitRequestFullscreen) { /* Chrome, Safari and Opera */
         console.log("fullscreen - webkit");
         elem.webkitRequestFullscreen();
      } else if (elem.msRequestFullscreen) { /* IE/Edge */
         console.log("fullscreen - ms");
         elem.msRequestFullscreen();
      } else {
         console.log("openFullscreen: found no match");
      }
   }
   
   function closeFullscreen() {
      if (document.exitFullscreen) {
         document.exitFullscreen();
      } else if (document.mozCancelFullScreen) { /* Firefox */
         document.mozCancelFullScreen();
      } else if (document.webkitExitFullscreen) { /* Chrome, Safari and Opera */
         document.webkitExitFullscreen();
      } else if (document.msExitFullscreen) { /* IE/Edge */
         document.msExitFullscreen();
      }
   }   

   function changeFullScreenMode( targetElement, mode) {
      if (mode == 'on') {
         if (targetElement.requestFullscreen) {
             targetElement.requestFullscreen();
         } else {
            console.log("can't find requestFullscreen method on target element.");
            // try a more general call (needed for browser in WebOS on an LG TV)
            openFullscreen( targetElement);
         }
         
      } else if (mode == 'off') {
         if (document.exitFullscreen) {
            // This check for fullscreenElement keeps a call to exitFullscreen from being attempted
            // if somehow the user has already exited fullscreen mode.
            // Refer to the event handler for fullscreenechange where this statement runs: twoThumbs.changeDisplay('exit');
            if (document.fullscreenElement) {
               document.exitFullscreen()
                  .then((    ) => console.log("scripted fullscreen exit went well."))
                  .catch((err) => console.log("caught error on fullscreen exit:" + err));
            } else {
               console.log("no fullscreenElement; changeFullScreenMode ran AFTER fullscreen exit.");
               // Try a more general call.
               closeFullscreen();
            }
         } else {
            console.log("can't find exitFullscreen method.");
         }
      }
   }
   
   function handle_sending_mK_data( mK) {
      // Use WebRTC datachannel if available
      if ( (cl.rtc) && (cl.rtc.dataChannel) && (cl.rtc.dataChannel.readyState == 'open') && (rtc_choke == false) ) {
         cl.rtc.dataChannel.send( JSON.stringify( mK));
      
      // Otherwise use socket.io (WebSocket)
      } else if (socket) {
         socket.emit('client-mK-event', JSON.stringify( mK));
      }
   }
   
   function debug( flag, message) {
      if (flag) console.log( message);
   }
   
   ////////////////////////////////////////////////////////////////////////////////
   // Event listeners to capture mouse and keyboard (m & K) state from the non-host 
   // clients. 
   ////////////////////////////////////////////////////////////////////////////////
   
   function initialize_mK() {
      // Initialize the Mouse and Keyboard (mK) state object.
      
      // isMouseDown
      mK.MD = false;
      // mouse button number (which of the three: 0,1,2)
      mK.bu = 0;  
      // mouse position in pixels: X_px, Y_px
      mK.mX = 5;
      mK.mY = 5; 
      // mouse wheel
      mK.mW = 'N'; // F,B, or N (forward, backward, or neutral)
      
      // Use the keyMap to define and initialize all the key states (to UP) in the 
      // mK (mouse and keyboard state) object that is sent to the host.
      for (var key in keyMap) {
         mK[keyMap[key]] = 'U';
      }
      for (var key in keyMap_cso) {
         mK_cso[keyMap_cso[key]] = 'U';
      }
      // Initialize non-keyboard attributes (for the Two Thumbs interface)
      // These values are default when using the keyboard.
      mK['ScRrf'] = 0.00; // gun scope rotation rate fraction (0.00, no constant sweeping)
      mK['ScTr'] = 'U';   // gun scope trigger ('U', scope trigger is off)
      mK['jet_t'] = 1.0;  // jet throttle (1.0, jet is full throttle)
   }
   
   function init_eventListeners_nonHostClients() {
      initialize_mK();
   
      clientCanvas = document.getElementById('connectionCanvas');
      ctx = clientCanvas.getContext('2d');
      
      clientCanvas_tt = document.getElementById('twoThumbsCanvas');
      ctx_tt = clientCanvas_tt.getContext('2d');
      
      myRequest = null;

      videoMirror = document.getElementById('videoMirror');
      videoMirrorDiv = document.getElementById('divForClientCanvas');
      
      // Event handlers for this network client (user input)
      
      // Inhibit the context menu that pops up when right clicking (third button).
      // Alternatively, could apply this only to the canvas. That way you can still
      // source the page.
      document.addEventListener("contextmenu", function(e) {
         e.preventDefault();
         return false;
      }, {capture: false});
      
      // For the client, keep these listeners on all the time so you can see the client cursor.
      // To avoid scrolling behavior on the video element, had to set up specific event handlers
      // for that element (videoMirror) and use preventDefault. This wasn't necessary on the host
      // side because no video element there (just a canvas).
      videoMirror.addEventListener("touchmove", function(e) {
         e.preventDefault();
         handleMouseOrTouchMove(e, 'touchmove');
      }, {capture: false});
      videoMirror.addEventListener("mousemove", function(e) {
         e.preventDefault();
         handleMouseOrTouchMove(e, 'mousemove');
      }, {capture: false});
      
      // This click handler on the parent div for the client canvas and streaming video elements
      // prevents click events from pausing the stream when in fullscreen mode (needed for Chrome).
      videoMirrorDiv.addEventListener("click", function(e) {
         e.preventDefault();
      }, {capture: false});
      
      clientCanvas_tt.addEventListener("touchmove", function(e) {
         e.preventDefault();
         handleMouseOrTouchMove(e, 'touchmove');
      }, {capture: false});
      
      document.addEventListener("mousedown", function(e) {
         // Keep mousedown from firing in the TwoThumbs interface. Necessary for Android where this
         // would fire after the touchstart and screw up the status dot. This of course prevents
         // use of the mouse in TwoThumbs, but that's fine, need fingers there...
         if (twoThumbs.enabled) return;
         
         mK.MD = true;  // Mouse Down
         mK.bu = e.button; // Mouse button
         
         //Pass this initial mouse position to the move handler.
         handleMouseOrTouchMove(e, 'mousedown');
         
         //if (cl.rtc && cl.rtc.dataChannel) cl.rtc.dataChannel.send( 'mouse-down event, id = ' + cl.rtc.dataChannel.id);
         
      }, {capture: false});
      
      document.addEventListener("touchstart", function(e) {
         // Note: the following canvas style is set:
         // touch-action: none;
         // This keep the canvas from sliding when flinging objects.
         
         // Prevent the mousedown event from firing. But in the end decided to put the check-and-return
         // statement in the first line of mousedown. That works in all devices and all browsers. The following
         // statement problematically blocked touch operations in off-canvas areas of the client when running in
         // Firefox.
         //e.preventDefault(); // works (to prevent mousedown) for laptop but not Android.
                  
         mK.MD = true;  // Mouse Down
         mK.bu = 0; // Mouse button
         
         //Pass this initial touch position to the move handler.
         handleMouseOrTouchMove( e, 'touchstart');
         
      }, {capture: false});      
      
      function tripleTap() {
         var now = new Date().getTime();
         var timesince = now - ts.previousTapTime;
         ts.previousTapTime = now;
         // A good short double tap
         if ((timesince > 0) && (timesince < 300)) {
            ts.tapCount += 1;
            // That's a triple.
            if (ts.tapCount == 3) {
               ts.tapCount = 1;
               return true;
            // Nice double, but not a triple.
            } else {
               return false;
            }
         // Too much time has passed, so reset.
         } else {
            ts.tapCount = 1;
            return false;
         }
      }
      
      function handleMouseOrTouchMove( e, fromListener) {
         // Process mousedown, mousemove, touchstart, and touchmove events.
         
         if (twoThumbs.enabled) {
            var touchPoints_2d_px = [];
            
            // Determine event type
            // Mouse (single contact point)
            if (e.clientX && (mK.MD == true)) {
               touchPoints_2d_px[0] = screenFromRaw_2d_px( clientCanvas_tt, new cP.Vec2D( e.clientX, e.clientY));
            
            // Touch screen (possibly multiple contact points)
            } else if (e.touches) {
               /*
               // Tried this but can't. Must start with a gesture on the host.
               // Use 4-finger touch to toggle fullscreen on the host.
               if ((e.touches.length == 4) && (fromListener != 'touchmove')) {
                  var control_message = {'from':cl.name, 'to':'host', 'data':{'fullScreen':'off'} };
                  socket.emit('control message', JSON.stringify( control_message));
               }
               */
               for (var i = 0, len = e.touches.length; i < len; i++) {
                  touchPoints_2d_px[i] = screenFromRaw_2d_px( clientCanvas_tt, new cP.Vec2D( e.touches[i].clientX, e.touches[i].clientY));
               }
            }
            // Interpret the touch and mouse events using the twoThumbs interface.
            twoThumbs.processMultiTouch( touchPoints_2d_px);
         
         // Non-twoThumbs
         } else {
            // If NOT in twoThumbs AND in the mobile state, don't send mouse or touch data.
            if (clientDeviceType == "mobile") return;
            
            // Determine event type
            // Mouse
            if (e.clientX) {
               var raw_x_px = e.clientX;
               var raw_y_px = e.clientY;
            
            // Translate touch-screen events (non-twoThumbs) into keyboard data for sending to the host 
            // for ghost-ball pool shots.
            // (see also resetMouseOrFingerState)
            } else if (e.touches) {
               var touchID = e.changedTouches[0].identifier;
               
               // Toggle ball-in-hand state when double-tap the first touch point. This is like holding
               // down the control key when using the keyboard.
               if ( (fromListener != 'touchmove') && (touchID == "0") ) {
                  if ( tripleTap() ) {
                     if (mK['ct'] == 'D') {
                        mK['ct'] = 'U';
                     } else {
                        mK['ct'] = 'D';
                     }
                  }
               }
               
               // Do nothing as the first and second touch points are started. 
               // (Cue ball shoots when one of the first two touch points is lifted.)
               if ((touchID == "0") || (touchID == "1")) {
               
               // Lock in a new value for cue-ball speed when third touch point is started.   
               } else if (touchID == "2") {
                  mK['z'] = 'D';
                              
               // Restart the game when forth touch point is started.
               } else if (touchID == "3") {
                  mK['3'] = 'D';
               }
               
               // Only consider the first touch point for establishing cursor position.
               var raw_x_px = e.touches[0].clientX;
               var raw_y_px = e.touches[0].clientY;
            }
            
            // Convert the raw mouse position into coordinated relative to the corner of the imaging element.
            var screen_2d_px = screenFromRaw_2d_px( videoMirror, new cP.Vec2D( raw_x_px, raw_y_px));
            
            // Send the state to the server (there it will be relayed to the host client).
            mK.mX = screen_2d_px.x;
            mK.mY = screen_2d_px.y;
            handle_sending_mK_data( mK);
         }
      };
         
      document.addEventListener("mouseup", function( e) {
         if (!mK.MD) return;
         
         // Unlike for the host client, DO NOT shut down the mousemove listener. That
         // way we can see the mouse position even if the buttons are released.
         
         resetMouseOrFingerState( e);
      }, {capture: false});
      
      document.addEventListener("touchend", function( e) {
         // Don't seem to need this...
         //if (!mK.MD) return;
         
         // Note: e.preventDefault() not needed here if the following canvas style is set
         // touch-action: none;
         
         resetMouseOrFingerState( e);
      }, {capture: false});
      
      function resetMouseOrFingerState( e) {
         // Process mouseup and touchend events.
         
         if (e.changedTouches) {
            var releasePoint_2d_px = screenFromRaw_2d_px( clientCanvas_tt, new cP.Vec2D( e.changedTouches[0].clientX, e.changedTouches[0].clientY));
            twoThumbs.processSingleTouchRelease(  releasePoint_2d_px);
         }
         
         mK.MD = false; // Mouse Down
         mK.bu = null;  // Mouse button number
         
         // Translate touch-screen events into keyboard data for pool shoots (see also handleMouseOrTouchMove)
         if (e.touches) {
            var touchID = e.changedTouches[0].identifier;
            
            // If one of the first two touches is lifted: shoot the cue ball
            if ((touchID == "0") || (touchID == "1")) {
               mK['z'] = 'U';
               mK['3'] = 'U';
               mK.MD = false; // shoot it
               
               // In the first two touch-release events, tell the host you're playing from a touchscreen. This is needed
               // for the ghost-ball pool (since the actual TwoThumbs interface is not used). However, this also fires for any
               // client using a touch screen. Note: this (touch-screen usage) will only show up in the leaderboard reporting for
               // ghost-ball pool if the network client takes a pool shot. The touchScreenUsage_sendCounter value is reset to zero 
               // on page load and also a normal re-connect with the client connect button.
               // (The "chat message" here, or ones similar, can be useful in debugging from the cell phone.)
               if ( gb.touchScreenUsage_sendCounter <= 1) {
                  // announce it just once
                  if ( gb.touchScreenUsage_sendCounter == 1) socket.emit('chat message', "touch screen in use");
                  // the control message will get sent twice, just to be sure
                  sendSocketControlMessage( {'from':cl.name, 'to':'host', 'data':{'touchScreenUsage':{'value':true}} } );
                  gb.touchScreenUsage_sendCounter += 1;
               }
               
            // If third touch point is lifted: reset the z key.
            } else if (touchID == "2") {
               mK['z'] = 'U';
               mK.MD = true; // don't shoot
               
            // If forth touch point is lifted: reset the 3 key (so ready to restart the game).
            } else if (touchID == "3") {
               mK['3'] = 'U';
               mK.MD = true; // don't shoot
            }
         }
         
         handle_sending_mK_data( mK);      
      }
      
      // Mouse-wheel events
      // (use document or videoMirror)
      videoMirrorDiv.addEventListener("wheel", function(e) {
         // Chrome doesn't seem to listen to these (in the normal way). Had to explicitly set passive:false (should be the default).
         // Also tried putting this listener on document, videoMirror, and videoMirrorDiv. But nothing works unless passive is false.
         e.preventDefault();
         // see style for videoMirror (touch-action: none;) in hostAndClient.css: stops scrolling and zooming behavior associated with mouse wheel.
         // Note the Chrome client can use a two-finger gesture on touch pad without getting scrolling/zooming behavior.
         
         if (e.deltaY < 0) {
            mK.mW = 'F';  // roll wheel forward
         } else {
            mK.mW = 'B';  // roll wheel back
         }
         handle_sending_mK_data( mK);
         mK.mW = 'N';

      }, {passive: false, capture: false});
      
      document.addEventListener("keydown", function( e) {
         //console.log("e.keyCode = " + e.keyCode);
         
         // This allows the spacebar to be used for the puck shields.
         if (keyMap[e.keyCode] == 'sp') {
            // Inhibit page scrolling that results from using the spacebar.
            e.preventDefault();
            // The following is necessary in Firefox to avoid the spacebar from re-clicking 
            // page controls (like the demo buttons) if they have focus.
            if (document.activeElement != document.body) document.activeElement.blur();
         }
         
         //console.log(e.keyCode + "(down)=" + String.fromCharCode(e.keyCode));
         
         if (e.keyCode in keyMap_cso) {
            //console.log("keyMap value = " + e.keyCode + ", " + keyMap_cso[e.keyCode]);   
            if (mK_cso[keyMap_cso[e.keyCode]] == 'U') {
               // Set the key to DOWN.
               mK_cso[keyMap_cso[e.keyCode]] = 'D';
            }
         }
         
         // Toggle the p2p connection (shift p)
         if ((mK_cso.key_p == 'D') && (mK_cso.key_shift == 'D')) {
            rtc_choke = !rtc_choke;
            refresh_P2P_indicator({'mode':'p2p', 'context':'chokeToggle'});
         
         // Esc out of full-screen mode (only mildly useful if the twothumbs checkbox is not hidden) 
         // If you're in fullscreen mode, this one won't
         // be the first to fire. The fullscreenchange handler fires first. Then, after
         // a second esc key press, this block will execute.
         } else if (keyMap_cso[e.keyCode] == 'key_esc') {
            //console.log('in key_esc block');
            
            // Reveal the video element (and hide the canvas).
            videoMirror.removeAttribute("hidden");
            clientCanvas_tt.setAttribute("hidden", null);
            
            chkTwoThumbs.checked = false;
            twoThumbs.enabled = false;
         }
         
         if (e.keyCode in keyMap) {
            //console.log("keyMap value = " + keyMap[e.keyCode]);      
            if (mK[keyMap[e.keyCode]] == 'U') {
               // Set the key to DOWN.
               mK[keyMap[e.keyCode]] = 'D';
               handle_sending_mK_data( mK);
            }
         }
         
      }, {capture: false}); //"false" makes this fire in the bubbling phase (not capturing phase).
      
      document.addEventListener("keyup", function(e) {
         //console.log(e.keyCode + "(up)=" + String.fromCharCode(e.keyCode));
         if (e.keyCode in keyMap) {
            // Set the key to UP.
            mK[keyMap[e.keyCode]] = 'U';               
            handle_sending_mK_data( mK);
         }
         if (e.keyCode in keyMap_cso) {
            // Set the key to UP.
            mK_cso[keyMap_cso[e.keyCode]] = 'U';               
         }
      }, {capture: false}); //"false" makes this fire in the bubbling phase (not capturing phase).
      
      // Video stream checkbox.
      chkRequestStream = document.getElementById('chkRequestStream');
      chkRequestStream.checked = false; 
      chkRequestStream.addEventListener("click", function() {
         
         // You checked it.
         if (chkRequestStream.checked) {
            // For now, leaving the full-screen button enabled at all times.
            //$('#FullScreen').prop('disabled', false);
            if ($('#roomName').val() == "") {
               displayMessage('');
               displayMessage('You must have a room name in the red box. Try again.');
               displayMessage('');
               chkRequestStream.checked = false;
            } else {
               
               if (chkTwoThumbs.checked) {
                  // Uncheck twoThumbs (but it's probably hidden unless I'm testing)
                  chkTwoThumbs.click();
               }
               
               // re-negotiate the connection.
               window.setTimeout( function() {
                  connect_and_listen('client', 're-connect-with-stream');
               }, 100);
            }
         // You unchecked it.
         } else {
            // For now, leaving the full-screen button enabled at all times.
            //$('#FullScreen').prop('disabled', true);
            if (socket) {
               var control_message = {'from':cl.name, 'to':'host', 'data':{'videoStream':'off'} };
               socket.emit('control message', JSON.stringify( control_message));
               
               // Wait a bit for the above message to get to the host. Then clean out the
               // video element.
               window.setTimeout(function() {
                  if (videoMirror.srcObject) videoMirror.srcObject = null;
               }, 200);
               
            } else {
               displayMessage('');
               displayMessage("If you haven't already, please connect to the host.");
            }
         }
      }, {capture: false});
      
      // This control can be useful for testing but is normally hidden. Edit client.html
      // to un-hide it.
      chkTwoThumbs = document.getElementById('chkTwoThumbs');
      chkTwoThumbs.checked = false;
      chkTwoThumbs.addEventListener("click", function() {
         if (chkTwoThumbs.checked) {
            twoThumbs.changeDisplay('normal');
         } else {
            twoThumbs.changeDisplay('exit');
         }
      }, {capture: false});
      
      // Button (on client) for starting the TwoThumbs interface
      btnTwoThumbs = document.getElementById('twoThumbsButton');
      btnTwoThumbs.addEventListener("click", function() {
         twoThumbs.changeDisplay('fullScreen');
      }, {capture: false});
      
      // Button (on client) for viewing the stream full-screen
      btnFullScreen = document.getElementById('FullScreen');
      btnFullScreen.addEventListener('click', function() {
         changeFullScreenMode( videoMirror, 'on');
      }, {capture: false});
      
      // Local cursor is handy if the engine is paused. Also give visual indicator of lag.
      chkLocalCursor = document.getElementById('chkLocalCursor');
      chkLocalCursor.checked = true;
      chkLocalCursor.addEventListener("click", function() {
         if (chkLocalCursor.checked) {
            videoMirror.style.cursor = 'default';
            clientCanvas_tt.style.cursor = 'default';
         } else {
            videoMirror.style.cursor = 'none';
            clientCanvas_tt.style.cursor = 'none';
         }
      }, {capture: false});
      
      // Option for connecting without a puck.
      chkPlayer = document.getElementById('chkPlayer');
      chkPlayer.checked = true;
      
      // For general handling of changes in fullscreen state.
      // Useful for handling the first press of the ESC key (exiting fullscreen mode)
      $(document).on('webkitfullscreenchange mozfullscreenchange fullscreenchange msfullscreenchange', function(e) {
         // Check for fullscreen-state change.
         
         // Starting fullscreen
         if (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) {
            console.log('fullscreen state: TRUE');
            gW.fullScreenState('on');
            videoMirror.style.borderWidth = '0px';
            
         // Exiting fullscreen
         } else {
            console.log('fullscreen state: FALSE');
            gW.fullScreenState('off');
            clientCanvas_tt.width  = videoMirror.width;
            clientCanvas_tt.height = videoMirror.height;
            videoMirror.style.borderWidth = '5px';
            // The following statement is needed for Firefox, video streaming,
            // and hiding the two-thumbs display (and revealing the video element).
            twoThumbs.changeDisplay('exit');
         }
      });
      
   }
   

   // Reveal public pointers to private objects ///////////////

   return {
      //nodeServerURL: nodeServerURL,
      forceClientDisconnect: forceClientDisconnect,
      resizeClients: resizeClients,
      sendSocketControlMessage: sendSocketControlMessage,
      init_chatFeatures: init_chatFeatures,
      init_nonHostClients: init_nonHostClients,
      connect_and_listen: connect_and_listen,
      refresh_P2P_indicator: refresh_P2P_indicator,
      setCanvasStream: setCanvasStream,
      changeFullScreenMode: changeFullScreenMode,
      chatToNonHostPlayers: chatToNonHostPlayers,
      displayMessage: displayMessage,
      checkForNickName: checkForNickName,
      clearInputDefault: clearInputDefault,
      restoreInputDefault: restoreInputDefault,
      RTC: RTC,
      gb: gb
   };
   
})();