// Game Window (gW) module
// gwModule.js 
   console.log('GW version 0.0');
// 5:59 PM Mon May 16, 2022
// Written by: James D. Miller 

/*
Dependencies for gwModule.js:
   constructorsAndPrototypes.js (cP.)
   hostAndClient.js (hC.)
   ghostBall.js (gB.)
   utilities.js
*/

var gW = (function() {
   "use strict";
   
   // Short names for Box2D constructors and prototypes
   var b2Vec2 = Box2D.Common.Math.b2Vec2,   
      b2BodyDef = Box2D.Dynamics.b2BodyDef,   
      b2Body = Box2D.Dynamics.b2Body,   
      b2FixtureDef = Box2D.Dynamics.b2FixtureDef,   
      b2Fixture = Box2D.Dynamics.b2Fixture,   
      b2World = Box2D.Dynamics.b2World,   
      b2Manifold = Box2D.Collision.b2Manifold,
      b2WorldManifold = Box2D.Collision.b2WorldManifold,
      b2DistanceJointDef = Box2D.Dynamics.Joints.b2DistanceJointDef,      
      b2DistanceJoint = Box2D.Dynamics.Joints.b2DistanceJoint,
      b2RevoluteJointDef = Box2D.Dynamics.Joints.b2RevoluteJointDef,
      b2RevoluteJoint = Box2D.Dynamics.Joints.b2RevoluteJoint,
      b2MassData = Box2D.Collision.Shapes.b2MassData,   
      b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape,   
      b2CircleShape = Box2D.Collision.Shapes.b2CircleShape,   
      b2AABB = Box2D.Collision.b2AABB;
   
   ////////////////////////////////////////////////////////////////////
   // Common variables inside of gW (game window) module //////////////
   ////////////////////////////////////////////////////////////////////
   
   // The Air Table (aT): a place to call home for pucks, pins, springs, joints, and walls.
   var aT = {};
  
   aT.puckMap = {}; // keyed by puck name.
   aT.jelloPucks = []; // An array for use in testing for tangled jello.
   
   aT.pinMap = {};  // keyed by pin name.
   
   aT.springMap = {}; // keyed by spring name.
   aT.jointMap = {}; // keyed by joint name.
   
   aT.wallMap = {}; // keyed by wall name.
   
   aT.collisionCount = 0;
   aT.collisionInThisStep = false;
   
   cP.Puck.restitution_default_gOn =  0.7;
   cP.Puck.friction_default_gOn =  0.6;
   cP.Puck.restitution_default_gOff = 1.0;
   cP.Puck.friction_default_gOff = 0.1;
   cP.Puck.bulletAgeLimit_ms = 1000;
   
   // Make a separate container for constants (c) and control flags used by aT objects. This avoids
   // circular references (and associated problems) with JSON capturing.
   var c = {};
   c.g_mps2 = 9.8;
   c.g_ON = false;
   c.px_per_m = null;
   
   // This 60 corresponds with the selected (default) value on the index.html page.
   c.frameRate = 60.0;
   // Seconds per frame
   c.deltaT_s = 1.0/c.frameRate;
   c.dtFloating = false;
   
   c.borderAndBackGroundColor = '#008080'; // '#008080';
   c.fullScreenState = false;
   
   c.fullScreenDemo = false;
   c.lockedAndLoaded = false;
   
   c.demoIndex = null;
   c.demoLoopIndex = 0;
   c.demoVersion = null;
   c.leaderBoardIndex = 0;
   
   //c.contactCounter = 0;
   c.jello = {};
   c.jello.tangleTimer_s = 0;
   c.jello.reported = false;
   c.jello.verifyingDeTangle = false;
   
   //c.puckPopperTimer_s = 0;
   //c.puckPopperPlayers = {'human':0,'drone':0};
   
   //c.npcSleep = false;
   //c.npcSleepUsage = false;
   
   c.lastClientToScoreHit = null;
   //c.territoryMarked = false;   
   
   c.chatLayoutState = 'notSetYet';
   
   c.singleStep = false;
   c.softConstraints_default = false;
   
   c.canvasColor = 'black';
   
   c.timeTipPool = 'time (seconds) to finish the pool game';
   c.scoreTip = '+200: win,\n+100: pop client or drone \n+50: pop regular puck \n+10: hit a puck with your bullet \n-10: get hit by somebody else's bullet \n-1: bad shot';
   c.scoreTipPool_9ball    = '+50: win \n+15: pocket a ball \n-5: take a shot \n-10: object ball not low ball \n-20: scratch the cue ball';
   c.scoreTipPool_rotation = '+50: win \n+15: pocket a ball \n-5: take a shot \n-10: object ball not low ball \n-20: scratch the cue ball';
   c.scoreTipPool_8ball    = '+50: win \n+15: pocket a ball \n-5: take a shot \n-10: object ball not in group \n-20: scratch the cue ball';
   
   c.piCalcs = {'enabled':true, 'usePiEngine':false, 'clacks':false};

   c.pauseErase = false;
   // The user can set this true by putting the string "lagtest" in the chat field before checking the local cursor box (under multiplayer). 
   // Use this to see little circles drawn at cursor position in handleMouseOrTouchMove for the host and updateClientState for network client's.
   // Must use p or alt-p to avoid the canvas erasing actions. See comments at the beginning of updateAirTable.
   c.lagTesting = false;
   aT.cursorSpeed_pxps = new cP.RunningAverage(30);
   
   // e.g. dF.drawCircle()
   var dF = new cP.DrawingFunctions();
   c.drawSyncImage = false;
   
   // Client map keyed by client name.
   var clients = {};
   
   var tableMap = new Map();  // Special map where keys can be objects (specifically, box2d objects here).
   var world, worldAABB;
   var myRequest, time_previous, dt_frame_ms, dt_frame_previous_ms, dt_frame_s, resumingAfterPause;
   var canvas, canvasDiv, ctx;
   
   
   var sounds = {
      'lowPop':  new cP.SoundEffect("sounds/puckpop_lower.mp3", 5),
      'highPop': new cP.SoundEffect("sounds/puckpop.mp3", 5),
      'clack2':  new cP.SoundEffect("sounds/clack_long.wav", 35)
   };
   
   var messages = {
      'ppTimer':    new cP.HelpMessage({'font':'14px Arial', 'color':'lightgray'}),
      'jelloTimer': new cP.HelpMessage({'font':'25px Arial', 'color':'lightgray'}),
      'score':      new cP.HelpMessage({'font':'18px Arial', 'color':'lightgray'}),
      'help':       new cP.HelpMessage({'font':'20px Arial', 'color':'lightgray'}),
      'help2':      new cP.HelpMessage({'font':'20px Arial', 'color':'lightgray'}),
      'win':        new cP.HelpMessage({'font':'20px Arial', 'color':'yellow'}),
      'lowHelp':    new cP.HelpMessage({'font':'20px Arial', 'color':'yellow'}),
      'gameTitle':  new cP.HelpMessage({'font':'50px Arial', 'color':'lightgray'}),
      'videoTitle': new cP.HelpMessage({'color':'lightgray'}),
   };
   
   var hostSelectBox = new cP.SelectBox({});
   var hostMSelect = new cP.MultiSelect();
   
   var piCalcEngine;
   
   // These are functions needing global scope, that are defined inside other functions. Also see the comments before init(). Global scope does NOT allow
   // functions defined in init() to be exported; nothing defined in init() (init is called after page load), can be exported.
   var key_b_handler, key_c_handler, key_n_handler, key_l_handler, clickToClearMulti, mouseUp_handler, wheelEvent_handler, comSelection_Toggle, pasteSpring, addRevoluteJoint;
   
   // Document Controls (dC).
   var dC = {};
   dC.gravity = null;
   dC.pause = null;
   dC.comSelection = null;
   dC.multiplayer = null;
   dC.stream = null;
   dC.editor = null;
   dC.localCursor = null;
      
   // Key values.
   var keyMap = {'48':'key_0', '49':'key_1', '50':'key_2', '51':'key_3', '52':'key_4', '53':'key_5', '54':'key_6', '55':'key_7', '56':'key_8', '57':'key_9',
                 '65':'key_a', '66':'key_b', '67':'key_c', '68':'key_d', '69':'key_e', '70':'key_f', '71':'key_g', 
                 '73':'key_i', '74':'key_j', '75':'key_k', '76':'key_l', '77':'key_m', '78':'key_n', '79':'key_o', 
                 '80':'key_p', '81':'key_q', '82':'key_r', '83':'key_s', 
                 '84':'key_t', '85':'key_u', '86':'key_v', '87':'key_w', '88':'key_x', '90':'key_z',
                 '16':'key_shift', '17':'key_ctrl', 
                 '18':'key_alt', // both left and right alt key on Windows
                 
                 '32':'key_space', '8':'key_backspace', '9':'key_tab',
                 
                 // Note that default behavior is blocked on all these arrow-key type keys. Search on
                 // editKeysMap in the handler for the keydown event.
                 // Exceptions to this are the key_+ and key_- number-pad keys that are in the allowDefaultKeysMap.
                 // This allows the desired native zoom feature when using the ctrl key along with these keys.  
                 '33':'key_pageUp', '34':'key_pageDown', 
                 '37':'key_leftArrow', '38':'key_upArrow', '39':'key_rightArrow', '40':'key_downArrow',
                 // These are the number pad +/- keys.
                 '107':'key_+', '109':'key_-',
                 // These are the +/- keys on the main keyboard.
                 '187':'key_=+', '189':'key_-_', // Chrome
                 '61':'key_=+',  '173':'key_-_', // Firefox
                 
                 '188':'key_lt', '190':'key_gt',
                 
                 '191':'key_questionMark',
                 
                 '219':'key_[', '221':'key_]',
                 
                 '225':'key_alt'};   // right-side alt key, needed for RPi
   
   var fileName = "gwModule.js";
   
   // Switch to enable debugging...
   var db = {};
   // ...of the WebRTC stuff.
   db.rtc = false;
   
   // supporting touch-screen event processing
   var ts = {};
   ts.previousTapTime = new Date().getTime();
   ts.tapCount = 1;
   
   
   ////////////////////////////////////////////////////////
   // Initialize a world in Box2D    
   ////////////////////////////////////////////////////////
   
   // Constraint on space in world
   worldAABB = new b2AABB();
   worldAABB.lowerBound.Set(-20.0, -20.0);
   worldAABB.upperBound.Set( 20.0,  20.0);
   
   // b2d world: set gravity vector to 0, allow sleep.
   world = new b2World( new b2Vec2(0, -0.0), true);
   
   // Event handlers for Box2D. Get collision information.
   
   var listener = new Box2D.Dynamics.b2ContactListener;
   listener.BeginContact = function( contact) {
      aT.collisionCount += 1;
      aT.collisionInThisStep = true;
      
      // Any collision will start the fadeout mode for the pathAfter drawing of the ghost ball.
      if (c.demoVersion.slice(0,3) == "3.d") {
         cP.Client.applyToAll( client => { 
            client.gBS.pathAfter.fadeOut = true;
         });
      }
      
      // Use the table map to get a reference back to a gW object.
      var body_A = tableMap.get( contact.GetFixtureA().GetBody());
      var body_B = tableMap.get( contact.GetFixtureB().GetBody());

      // Ghost puck sensor event associated with client cursor. (also see drawGhostBall in ghostBall.js)
      if ((body_A.constructor.name == "Client") || (body_B.constructor.name == "Client")) {
         
         if (body_A.constructor.name == "Client") {
            // Yes, I put the client into the tableMap (wow!). The (ghost) b2d sensor is an attribute on the client.
            var clientTarget = body_B, client = body_A; 
         } else {
            var clientTarget = body_A, client = body_B;
         }
         // ignore contact between the sensor (the ghost) and the source puck (selected by client).
         if ((client.selectedBody) && (clientTarget.name != client.selectedBody.name)) {
            
            // if no longer in contact with the previous target (it's null now), identify this contact as the new target.
            if ( ! client.sensorTargetName) {
               client.sensorTargetName = clientTarget.name;
            }
         }

      // Set the wall color to that of the puck hitting it.
      } else if ((body_A.constructor.name == "Wall") || (body_B.constructor.name == "Wall")) {
         if (body_B.constructor.name == "Puck") {
            var body_Puck = body_B, body_Wall = body_A;
         } else {
            var body_Puck = body_A, body_Wall = body_B;               
         }
         
         // If it's a puck designated as a color source, use its client color for the wall.
         if (body_Puck.colorSource) {
            if (body_Puck.clientName && body_Wall.fence) {
               body_Wall.color = clients[body_Puck.clientName].color;
            } else if (body_Wall.fence) {
               body_Wall.color = body_Puck.color;
            }
         } else {
            // Reset the wall color to it's default.
            body_Wall.color = cP.Wall.color_default;               
         }
         
         // Ghost-ball pool
         if (c.demoVersion.slice(0,3) == "3.d") {
            gB.setCushionCollision(true);
            
         } else if (c.demoVersion == "5.e.basketball") {
            bpH.processBasketBallCollisions( body_Wall, body_Puck);
         }
                  
      } else if ((body_A.constructor.name == "Puck") && (body_B.constructor.name == "Puck")) {
         // Handle the case where one body is a bullet and one is not.
         if ((body_A.bullet && !body_B.bullet) || (body_B.bullet && !body_A.bullet)) {
            
            if (body_A.bullet && !body_B.bullet) {
               var bullet = body_A, target = body_B;
            } else if (body_B.bullet && !body_A.bullet) {
               var bullet = body_B, target = body_A;
            }
            
            // Check for restrictions on friendly fire AND that both target and shooter are human.
            var friendlyFire = false;
            if (! dC.friendlyFire.checked) {
               if (target.clientName && !target.clientName.includes('NPC') && !bullet.clientNameOfShooter.includes('NPC')) {
                  friendlyFire = true;
               }
            }
            
            // Can't shoot yourself in the foot and can't be friendly fire.
            if ((bullet.clientNameOfShooter != target.clientName) && !friendlyFire) {
               if (!target.shield.ON || (target.shield.ON && !target.shield.STRONG)) {
                  target.hitCount += 1;
                  target.inComing = true;
                  target.flash = true;
                  bullet.atLeastOneHit = true;
                  
                  // Give credit to the shooter (owner of the bullet).
                  if (!cP.Client.winnerBonusGiven && clients[ bullet.clientNameOfShooter]) {
                     clients[ bullet.clientNameOfShooter].score += 10;
                     // Keep track of the last successful hit to a client. Useful with multiple players and when friendly fire is blocked.
                     if (target.clientName) c.lastClientToScoreHit = bullet.clientNameOfShooter;
                  }
                  target.whoShotBullet = bullet.clientNameOfShooter;
                  // Remove credit from the puck that got hit (the not-bullet body).
                  if (!cP.Client.winnerBonusGiven && target.clientName && clients[ target.clientName]) {
                     clients[ target.clientName].score -= 10;
                  }
               }
            }
            
         // both bodies are pucks (and both bullets or both non-bullets) and in ghost-ball pool
         } else if (c.demoVersion.slice(0,3) == "3.d") {
            gB.processCueBallFirstCollision( body_A, body_B);
         }
         
         /*
         // exchange the puck colors
         // see colorExchange attribute for pucks
         if ( (body_A.colorExchange) && (body_B.colorExchange) ) {
            let body_B_color = body_B.color;
            body_B.color = body_A.color;
            body_A.color = body_B_color;
         }
         */
      } 
   }
   listener.PreSolve = function( contact) {
      gB.contactNormals('preSolve', contact);
   }
   listener.EndContact = function( contact) {
      gB.contactNormals('endContact', contact);
   }
   world.SetContactListener( listener);
   
   /////////////////////////////////////////////////////////////////////////////
   ////
   ////  Object Prototypes
   ////
   /////////////////////////////////////////////////////////////////////////////

   // see utilities.js and constructorsAndPrototypes.js
   
   /////////////////////////////////////////////////////////////////////////////
   ////
   ////  Functions
   ////
   /////////////////////////////////////////////////////////////////////////////
   
   // Misc utility stuff
   
   function pointInCanvas( p_2d_px) {
      var theRectangle = { 'UL_2d':{'x':0,'y':0}, 'LR_2d':{'x':canvas.width,'y':canvas.height} };
      return pointInRectangle( p_2d_px, theRectangle);
   }
   
   function pointInRectangle( p_2d, rect) {
      // UL: upper left corner, LR: lower right corner.
      if ( (p_2d.x > rect.UL_2d.x) && (p_2d.x < rect.LR_2d.x) && (p_2d.y > rect.UL_2d.y) && (p_2d.y < rect.LR_2d.y) ) {
         return true;
      } else {
         return false;
      }
   }
  
   
   // Support for the network client ///////////////////////////////////////////
   
   function createNetworkClient( pars) {
      var clientName = setDefault( pars.clientName, 'theInvisibleMan');
      // "player" is true/false to indicate if the client is requesting that a player puck be 
      // added to the client instance.
      var player = setDefault( pars.player, true);
      var nickName = setDefault( pars.nickName, null);
      
      var n = clientName.slice(1);
      // Repeat the color index every 10 users (10 colors in cP.Client.colors)
      var colorIndex = n - Math.trunc(n/10)*10;
      
      var clientPars = {};
      clientPars.player = player;
      clientPars.nickName = nickName;
      clientPars.color = cP.Client.colors[ colorIndex];
      clientPars.nameFromServer = clientName;
      clientPars.name = clientName;
      
      // if client is joining a ghost-ball pool game that's underway, initialize these.
      if (c.demoVersion.slice(0,3) == "3.d") {
         clientPars.ctrlShiftLock = true;
         clientPars.poolShotLocked = true;
         clientPars.poolShotLockedSpeed_mps = 20;
      }
      
      new cP.Client( clientPars);
   }
   
   function deleteNetworkClient( clientName) {
      // This function does not directly remove the client socket at the node server, but
      // that does happen at the server...
      if (db.rtc) console.log('in gW.deleteNetworkClient, clientName=' + clientName + ", fileName="+fileName);
      
      if (clients[clientName]) {
         // If it's driving a puck. First, delete that.
         if (clients[clientName].puck) {
            var thePuck = clients[clientName].puck
            
            // Remove this puck and do associated clean-up.
            thePuck.jet = null;
            thePuck.gun = null;
            thePuck.shield = null;
            tableMap.delete( thePuck.b2d);
            world.DestroyBody( thePuck.b2d);
            delete aT.puckMap[ thePuck.name];
         }
         deleteRTC_onHost( clientName);
      }
   }
   
   function deleteRTC_onHost( clientName) {
      if (db.rtc) console.log('in deleteRTC_onHost');
   
      // Shutdown and nullify any references to the host side of this WebRTC p2p connection.
      if (clients[clientName].rtc) {
         clients[ clientName].rtc.shutdown();
      }
      
      // Remove the client in the clients map.
      if (clients[clientName]) {
         delete clients[ clientName];
      }
   }
   
   function deleteRTC_onClientAndHost( clientName) {
      if (db.rtc) console.log('in deleteRTC_onClientAndHost');
      
      // Remove network clients on the node server.
      // (Note: this is one of the several places where hC is used inside of gW.)
      if (clientName.slice(0,1) == 'u') {
         // Send message to the server and then to the client to disconnect.
         hC.forceClientDisconnect( clientName);
      }
      
      // Remove the client in the clients map.
      deleteRTC_onHost( clientName);
   }
   
   function updateClientState( clientName, state) {
      /*
      This is mouse, keyboard, and touch-screen input as generated from non-host-client (network)
      events. Note that this can happen at anytime as triggered by events on 
      the client. This is not fired each frame.
      
      Repetition can be an issue here as mouse movement will repeatedly send 
      the state. If you want to avoid repeating actions, it may be appropriate 
      here to compare the incoming state with the current client state (or 
      make use of a key_?_enabled properties) to stop after the first act. 
      This blocking of repetition does not necessarily need to happen here. 
      For an example of this, search on key_i_enabled.
      
      It is handy to do the blocking here because you have access to the incoming
      state and don't need the key_?_enabled properties. But for actions that are
      repeating each frame, you need to use the key_?_enabled approach.
      */
      
      if (clients[ clientName]) {
         var client = clients[ clientName];
         
         if ((state.MD) && ( ! client.mouseDown)) {
            // Similar to how the host client can clear multi-select by clicking on an open space.
            clickToClearMulti( clientName);
            if (state.MD == 'T') client.touchScreenUsage = true;
         
         } else if (( ! state.MD) && (client.mouseDown)) {
            // Put this mouseUp handler here to try and improve the feeling of the puck fling, click-drag-release, for the
            // network clients. Just reproducing what is being done for the host client. Not sure it helped.
            // The "feel" issue is more likely related to latency.
            mouseUp_handler( clientName);
         }
         client.mouseDown = state.MD;
         
         if ((client.mouseDown == 'M') && pointInCanvas( client.mouse_2d_px)) {
            // If there's been a click on the canvas area, flag it as mouse usage.
            // This should prevent cell-phone clients from getting flagged here unless they
            // have a mouse connected and click on the canvas before getting into virtual game pad.
            client.mouseUsage = true;
         }
         
         client.button = state.bu;
         
         var posOnCanvas_2d_px = new cP.Vec2D( state.mX, state.mY);
         // facilitate high-resolution cursor movements
         var finalPosOnCanvas_2d_px = fineMoves( clientName, posOnCanvas_2d_px);
         if (c.lagTesting) dF.drawCircle( ctx, finalPosOnCanvas_2d_px, {'borderWidth_px':0, 'fillColor':'white', 'radius_px':3});
         if (client.fineMovesState != 'inTransition') {
            client.mouse_async_2d_px = finalPosOnCanvas_2d_px;
         }
         
         if (state.mW == 'F') wheelEvent_handler( clientName, {'deltaY':1});
         if (state.mW == 'B') wheelEvent_handler( clientName, {'deltaY':-1});
         
         client.key_a = state.a;
         client.key_s = state.s;  // key_s_enabled inhibits key-held-down repeats
         client.key_d = state.d;
         client.key_w = state.w;
         
         client.key_j = state.j;
         client.key_k = state.k;  // uses key_k_enabled
         if ((state['l'] == "D") && (client.key_l == "U")) {
            key_l_handler('keydown', clientName);
         }
         client.key_l = state.l;
         client.key_i = state.i;  // uses key_i_enabled
         
         client.key_space = state.sp;
         client.key_questionMark = state.cl; //cl short for color
         
         client.key_alt = state.alt;
         
         // Compare incoming state with the current state. Only act if changing from U to D.
         if ((state['1'] == "D") && (client.key_1 == "U")) demoStart(1);
         client.key_1 = state['1'];
         
         if ((state['2'] == "D") && (client.key_2 == "U")) demoStart(2);
         client.key_2 = state['2'];
         
         if ((state['3'] == "D") && (client.key_3 == "U")) {
            // Play 9-ball if no pool game is loaded.
            if (c.demoVersion.slice(0,3) == "3.d") {
               demoStart(3);                 
            } else {
               demoStart_fromCapture(3, {'fileName':'demo3d.js'})
            }
         }
         client.key_3 = state['3'];
         
         if ((state['4'] == "D") && (client.key_4 == "U")) demoStart(4);
         client.key_4 = state['4'];
         
         if ((state['5'] == "D") && (client.key_5 == "U")) demoStart(5);
         client.key_5 = state['5'];
         
         if ((state['6'] == "D") && (client.key_6 == "U")) demoStart(6);
         client.key_6 = state['6'];
         
         if ((state['7'] == "D") && (client.key_7 == "U")) demoStart(7);
         client.key_7 = state['7'];
         
         if ((state['8'] == "D") && (client.key_8 == "U")) demoStart(8);
         client.key_8 = state['8'];
         
         if ((state['9'] == "D") && (client.key_9 == "U")) demoStart(9);
         client.key_9 = state['9'];
         
         if ((state['f'] == "D") && (client.key_f == "U")) freeze();
         client.key_f = state['f'];
         
         
         // Similar to how the ctrl events are handled for the host (local client).
         if ((state['ct'] == "D") && (client.key_ctrl == "U")) {
            key_ctrl_handler('keydown', clientName);
         }
         if ((state['ct'] == "U") && (client.key_ctrl == "D")) {
            key_ctrl_handler('keyup', clientName);
         }
         client.key_ctrl = state['ct'];
         
         if ((state['b'] == "D") && (client.key_b == "U")) {
            key_b_handler( clientName);
         }
         client.key_b = state['b'];
         
         if ((state['c'] == "D") && (client.key_c == "U")) {
            key_c_handler( clientName);
         }
         client.key_c = state['c'];
         
         if ((state['n'] == "D") && (client.key_n == "U")) {
            key_n_handler( clientName);
         }
         client.key_n = state['n'];
         
         // Releasing the shift key (similar to event handler for local client).
         if ((state['sh'] == "U") && (client.key_shift == "D")) {
            // Done with the rotation action. Get ready for the next one.
            hostMSelect.resetCenter();
            client.modifyCursorSpring('dettach');
         }
         client.key_shift = state.sh;
         
         // Set pool shot speed.
         if ( (state.z == "D") && (client.key_z == "U") && (((client.key_shift == "D") && (client.key_ctrl == "D")) || (client.ctrlShiftLock)) ) {
            gB.togglePoolShotLock( client);
         }
         client.key_z = state.z;
         
         // Specific angle being sent from client in TwoThumbs mode.
         if (client.puck && state['jet_d']) {
            client.puck.jet.rotateJetToAngle( state['jet_d']);
         }
         if (client.puck && state['gun_d']) {
            client.puck.gun.rel_position_2d_m.set_angle( state['gun_d']);
            // Flag this client as using the virtual game pad during this game.
            client.virtualGamePadUsage = true;
         }
         
         // Special Two Thumbs controls.
         if (client.puck) {
            // Jet throttle
            client.puck.jet.throttle = state['jet_t'];
            
            // Gun Scope: rotation rate fraction   and   firing trigger 
            // Freeze the puck at the first press of the scope trigger or rotator. If external forces
            // move the puck after this freeze event, so be it.
            if ((client.puck.gun.scopeTrigger == 'U')     && (state['ScTr']  == 'D') ||
                (client.puck.gun.scopeRotRateFrac == 0.0) && (state['ScRrf'] != 0.0)) {
               
               // Check if it's moving before breaking (and drawing the break circle).
               var v_2d_mps = client.puck.velocity_2d_mps;
               if ((Math.abs( v_2d_mps.x) > 0) || (Math.abs( v_2d_mps.y) > 0)) {
                  client.puck.b2d.SetLinearVelocity( new b2Vec2(0.0,0.0));
                  client.puck.gun.scopeBreak = true;
               }
            }
            client.puck.gun.scopeRotRateFrac = state['ScRrf'];
            client.puck.gun.scopeTrigger = state['ScTr'];
         }
         /*         
         var stateString = "";
         for (var key in state) stateString += key + ":" + state[ key] + ",";
         console.log("stateString=" + stateString);
         */
      }
   }
   
   function setClientCanvasToMatchHost() {
      // This must run within the context of the host's browser (to get the host's canvas dimensions).
      hC.sendSocketControlMessage({'from':'host', 'to':'roomNoSender', 'data':{'canvasResize':{'width':canvas.width,'height':canvas.height}, 'demoVersion':c.demoVersion} });
   }
   
   
   // box2d functions to interact with the engine //////////////////////////////
   
   function b2d_getBodyAt( mousePVec_2d_m) {
      var x = mousePVec_2d_m.x;
      var y = mousePVec_2d_m.y;
      var aabb = new b2AABB();
      var size_m = 0.001;
      aabb.lowerBound.Set(x - size_m, y - size_m);
      aabb.upperBound.Set(x + size_m, y + size_m);
      
      // Query the world for overlapping bodies. Where the body's bounding box overlaps
      // with the aabb box defined above. Run the function provided to QueryAABB for each
      // body found to overlap the aabb box.

      var selectedBody = null;
      var userData = null;
      world.QueryAABB( function( fixture) {
         // Don't consider cursor pins.
         if (!tableMap.get( fixture.GetBody()).cursorPin) {
            // Take the first fixture where this point can be found locally on it.
            if (fixture.GetShape().TestPoint(fixture.GetBody().GetTransform(), mousePVec_2d_m)) {
               selectedBody = fixture.GetBody();
               userData = selectedBody.GetUserData();
               // Skip ghost sensors; try to keep looking for a table object.
               if (userData != "ghost-sensor") {
                  return false; // stop checking the query results
               }
            }
         }
         // return true to continue checking at the rest of the fixtures returned by the query
         return true;
      }, aabb);
      // If the last, or only object found is a ghost, don't return it.
      if (userData != "ghost-sensor") {
         return selectedBody;
      } else {
         return null;
      }
   }  

   function b2d_getPolygonVertices_2d_px( b2d_body) {
      // Make an array that has the world vertices scaled to screen coordinates.
      var poly_2d_px = [];
      for (var i = 0; i < b2d_body.m_fixtureList.m_shape.m_vertices.length; i++) {
         var p_2d_px = screenFromWorld( b2d_body.GetWorldPoint( b2d_body.m_fixtureList.m_shape.m_vertices[i]));
         poly_2d_px.push( p_2d_px);
      }
      return poly_2d_px;
   }
   
   function b2d_getPolygonVertices_2d_m( b2d_body) {
      // Make an array that has the world vertices.
      var poly_2d_m = [];
      for (var i = 0; i < b2d_body.m_fixtureList.m_shape.m_vertices.length; i++) {
         var p_2d_m = Vec2D_from_b2Vec2( b2d_body.GetWorldPoint( b2d_body.m_fixtureList.m_shape.m_vertices[i]));
         poly_2d_m.push( p_2d_m);
      }
      return poly_2d_m;
   }
   
   
   // Key handlers that can be exported (i.e. defined outside of init()) //////////////////////////////////////
   
   function key_ctrl_handler( mode, clientName) {
      let client = clients[ clientName];
      
      let messageString = "";
      let wordForPuck = (c.demoVersion.slice(0,3) == "3.d") ? 'ball' : 'puck';
      
      if (mode == 'keydown') {
         // When ctrl is depressed, set cursor spring attachment point to the original selection point (not COM).
         if (client.selectedBody) client.cursorSpring.spo2_ap_l_2d_m = client.selectionPoint_l_2d_m;
         if (client.touchScreenUsage) {
            messageString = client.nameString() + " has " + wordForPuck + "-in-hand [base,yellow]ON[base]";
         }
      } else if (mode == 'keyup') {
         if (client.touchScreenUsage) {
            messageString = client.nameString() + " turned " + wordForPuck + "-in-hand [base,yellow]OFF[base]"; 
         }
         
         // Done with the rotation action. Get ready for the next one.
         hostMSelect.resetCenter();
         
         if (client.selectedBody) {
            // Release the one-at-a-time choke on direct movement.
            if (client.name == client.selectedBody.firstClientDirectMove) client.selectedBody.firstClientDirectMove = null;          
            
            // When releasing the ctrl key, change the cursor spring attachment point according to the
            // COM selection control.
            if (dC.comSelection.checked) {
               client.cursorSpring.spo2_ap_l_2d_m = new cP.Vec2D(0,0);
            } else {
               client.cursorSpring.spo2_ap_l_2d_m = client.selectionPoint_l_2d_m;
            }
         }
         
         // Detach the cursor spring. This prevents unintended movement when releasing the control key.
         if (client.key_shift == "D") client.modifyCursorSpring('dettach');
         
      } else {
         console.log("not good to be in here...");
      }
      if (messageString != "") messages['help'].newMessage( messageString, 1.0);
   }
   
 
   // Functions called by the buttons //////////////////////////////////////////
   
   function toggleMultiplayerStuff() {
      // This double toggle has the effect of switching between the following two divs.
      toggleElementDisplay("multiPlayer", "table-cell");
      toggleElementDisplay("ttcIntro",    "table-cell");
      
      // This toggles (displays/hides) the client links.
      toggleElementDisplay("clientLinks", "block");
      
      // Update the help panel's scroll position.
      if (c.currentScrollTarget) {
         scrollDemoHelp( c.currentScrollTarget, 0);
      } else {
         scrollDemoHelp('#d' + c.demoIndex, 0);
      }
   }
   
   function toggleElementDisplay( id, displayStyle) {
      var e = document.getElementById( id);
      // Use ternary operator (?):   condition ? expr1 : expr2
      // If the current style isn't equal to the incoming displayStyle, set it to be displayStyle. 
      // If it is equal, set it to 'none'. When the value is 'none', the element is hidden.
      // The effect of this function is that repeated calls to it, with the same displayStyle value, will
      // toggle the style between 'none' and the specified style value.
      e.style.display = (e.style.display != displayStyle) ? displayStyle : 'none';
   }
   function setElementDisplay( id, displayStyle) {
      var e = document.getElementById( id);
      e.style.display = displayStyle;
   }
   
   function toggleSpanValue( id, value1, value2) {
      var e = document.getElementById( id);
      e.innerText = (e.innerText == value1) ? value2 : value1; 
   }
   
   function getSpanValue( id) {  
      var e = document.getElementById( id);
      return e.innerText;
   }
   
   function resetFenceColor( newColor) {
      cP.Wall.applyToAll( wall => {
         if (wall.fence) {
            wall.color = newColor;
            wall.draw( ctx);
         }
      });
   }
   
   function setPauseState( e) {
      // Make the pause state agree with the check box.
      if (dC.pause.checked) {
         messages['help'].newMessage('Physics engine is [base,yellow]paused[base]. \\  Use the [base,yellow]"o"[base] key to single-step it, [base,yellow]"p"[base] to resume.', 0.1);
         // Wait for one frame, so the message to be displayed, then pause the engine.
         window.setTimeout( function() {
            stopit();
            setElementDisplay("fps_wrapper", "none");
            setElementDisplay("stepper_wrapper", "inline");
         }, 20);
      } else {
         startit();
         c.singleStep = false;
         setElementDisplay("fps_wrapper", "inline");
         setElementDisplay("stepper_wrapper", "none");
      }
   }
   
   function restartAnimationLoop( delay_ms) {
      if ((hC.getHostOrClient() == "host") && ( ! dC.pause.checked)) {
         dC.pause.checked = true; 
         stopit();
         
         window.setTimeout( function() {
            dC.pause.checked = false; 
            startit();
         }, delay_ms);
         
         console.log("animation restarted");
      }
   }
   
   function startit() {
      // Only start a game loop if there is no game loop running.
      if (myRequest === null) {
         resetFenceColor( cP.Wall.color_default);
         if ( ! c.singleStep) dC.pause.checked = false;

         // Start the game loop.
         myRequest = window.requestAnimationFrame( gameLoop);
      }
   }

   function stopit() {
      resetFenceColor( "red");
      aT.dt_RA_ms.reset();
      dC.fps.innerHTML = '0';

      window.cancelAnimationFrame( myRequest);
      myRequest = null;
      resumingAfterPause = true;
   }
   
   function stepAnimation() {
      dC.pause.checked = true;
      // Set flag to allow only one step.
      c.singleStep = true;
      startit();
   }
   
   function setFrameRateBasedOnDisplayRate() {
      console.log("fps=" + dC.fps.innerHTML);
      var current_fps = dC.fps.innerHTML;
      var fps_choices = [60,75,85,100,120,144,240];
      var min_diff = 1000;
      var min_diff_index = null;
      var len = fps_choices.length;
      for (var i = 0; i < len; i++) {
         var diff = Math.abs( fps_choices[i] - current_fps);
         if (diff < min_diff) {
            min_diff = diff;
            min_diff_index = i;
         }
      }
      var bestMatch = fps_choices[ min_diff_index];
      // Set the value in the pulldown control.
      $('#FrameRate').val( bestMatch);
      setFrameRate();
   }
   
   function setFrameRate() {
      var frameRate = $('#FrameRate').val();
      if (frameRate != 'float') {
         c.frameRate = frameRate;
         c.deltaT_s = 1.0 / frameRate;
         c.dtFloating = false;
      } else {
         c.dtFloating = true;
      }
   }
   
   function newtonsCradle( n_balls = 5, n_moving = 1) {
      hostMSelect.resetAll();
      clearTable("all");
      
      canvas.width = 950, canvas.height = 800;
      c.g_ON = true;
      dC.gravity.checked = c.g_ON;
      setGravityRelatedParameters({});
      
      for (let i = 0; i < n_balls; i++) {
         let jointLength_m = 6.0;  // 6
         
         let puckRadius_m = 0.5;
         // must have some separation
         let puckSeparation_m = (2 * puckRadius_m) + 0.0001; // 0.0001, 0.001 (5 balls, 1 moving)
         
         let puck_placement_2d_m = new cP.Vec2D(5.0 + (i * puckSeparation_m), 1.5);
         let velocity_2d_mps = new cP.Vec2D(0.0, 0.0);
         let puckPars = {'bullet':true, 'radius_m':puckRadius_m, 'restitution':1.00, 'restitution_fixed':true, 'linDamp':0.0, 'friction':0.0, 'friction_fixed':true, 
                         'angleLine':false, 'colorExchange':true, 'color':'tan'};
         let puck = new cP.Puck( puck_placement_2d_m, velocity_2d_mps, puckPars);
         
         // pins are directly above the pucks
         let pin_placement_2d_m = puck_placement_2d_m.add( new cP.Vec2D(0.0, jointLength_m));
         let pin = new cP.Pin( pin_placement_2d_m, {'radius_px':4, 'fillColor':'tan', 'borderColor':'brown'});
         
         new cP.Spring(puck, pin, {'softConstraints':true, 'fixedLength':true, 'length_m':jointLength_m, 'color':'brown'});
         
         if (i <= n_moving-1) {
            puck.velocity_2d_mps = new cP.Vec2D(-8.0, 0.0); 
         } else {
            puck.velocity_2d_mps = new cP.Vec2D(0.0, 0.0);
         }
         puck.b2d.SetLinearVelocity( puck.velocity_2d_mps);
      } 
   }
   
   function tableActions() {
      let selectedAction = $('#TableActions').val();
      let placement_2d_m, placementByMouse;
      
      // Don't use clients['local'].mouse_2d_px because that gets clipped in the case of ghost-ball to stay on the table.
      // Refer to the "if (runningGhostBallPool)" block in screenFromRaw_2d_px. So instead, start with the raw
      // screen position and run screenFromRaw_2d_px without clipping (don't specify ghost-ball).
      let positionOnCanvas_2d_px = screenFromRaw_2d_px( canvas, clients['local'].raw_2d_px);
      
      placementByMouse = false;
      if (pointInCanvas( positionOnCanvas_2d_px)) {
         placement_2d_m = worldFromScreen( positionOnCanvas_2d_px);
         placementByMouse = true;
      } else {
         placement_2d_m = new cP.Vec2D(2.0,2.0);
         placementByMouse = false;
      }
      
      if (selectedAction == "puck-rect") {
         new cP.Puck( placement_2d_m, new cP.Vec2D(0.0,0.0), {'half_width_m':0.2, 'half_height_m':0.2, 'shape':'rect'});
         
      } else if (selectedAction == "puck-rect-bullet") {
         new cP.Puck( placement_2d_m, new cP.Vec2D(0.0,0.0), {'half_width_m':0.6, 'half_height_m':0.02, 'shape':'rect', 'bullet':true});
         
      } else if (selectedAction == "toggleBulletSelected") {
         if (hostMSelect.count() == 0) {
            messages['help'].newMessage( hostMSelect.count() + " selected; need at least one.", 2.0);
         } else {
            let n_turnedOn = 0;
            let n_turnedOff = 0;
            hostMSelect.applyToAll( tableObj => {
               if (tableObj.constructor.name == "Puck") {
                  tableObj.bullet = ( ! tableObj.bullet);
                  tableObj.b2d.SetBullet( tableObj.bullet);
                  if (tableObj.bullet) {
                     n_turnedOn++;
                     tableObj.bulletIndication = true;
                  } else {
                     n_turnedOff++;
                     tableObj.bulletIndication = false;
                  }
               }
            });
            messages['help'].newMessage( "[base,yellow]" + n_turnedOn + "[base] turned on; [base,yellow]" + n_turnedOff + "[base] turned off", 2.5);
         }
         
      } else if (selectedAction == "revealBullets") {
         let n_bullets = 0;
         cP.Puck.applyToAll( puck => {
            if (puck.bullet) {
               n_bullets++;
               puck.bulletIndication = true;
            }
         });         
         let bulletString = (n_bullets == 1) ? 'bullet':'bullets';
         messages['help'].newMessage( "[base,yellow]" + n_bullets + "[base] " + bulletString + " found.", 2.5);
         
      } else if (selectedAction == "hideBullets") {
         let n_bullets = 0;
         cP.Puck.applyToAll( puck => {
            if (puck.bullet) {
               n_bullets++;
               puck.bulletIndication = false;
            }
         });         
         let bulletString = (n_bullets == 1) ? 'bullet':'bullets';
         messages['help'].newMessage( "[base,yellow]" + n_bullets + "[base] " + bulletString + " hidden.", 2.5);
         
      } else if (selectedAction == "wall") {
         new cP.Wall( placement_2d_m, {'half_width_m':0.6, 'half_height_m':0.02});
         
      } else if (selectedAction == "pin") {
         new cP.Pin( placement_2d_m, {'fillColor':'lightBlue'});
         
      } else if (selectedAction == "puck-circle") {
         new cP.Puck( placement_2d_m, new cP.Vec2D(0.0,0.0), {'radius_m':0.2});
         
      } else if (selectedAction == "puck-circle-bullet") {
         new cP.Puck( placement_2d_m, new cP.Vec2D(0.0,0.0), {'radius_m':0.2, 'bullet':true});
         
      } else if (selectedAction == "puck-circular-tail") {
         new cP.Puck( placement_2d_m, new cP.Vec2D(0.0,0.0), {'radius_m':0.2,
                                                              'createTail':true,
                                                              'tailPars':{
                                                                 'propSpeed_ppf_px':2, 'length_limit':35, 'color':'lightgray',
                                                                 'rainbow':false, 'rbSaturation': 75, 'rbLightness': 40,
                                                                 'machSwitch':false, 'machValue':0}});
         
      } else if (selectedAction == "spring") {
         pasteSpring(true);
         
      } else if (selectedAction == "clear-table") {
         clearTable("all");
         
      } else if (selectedAction == "clear-all-but-walls") {
         clearTable("all-but-walls");
         
      } else if (selectedAction == "clear-all-but-fence") {
         clearTable("all-but-fence");
         
      } else if (selectedAction == "clear-fence") {
         cP.Wall.deleteFence();
         
      } else if (selectedAction == "add-fence") {
         cP.Wall.makeFence({}, canvas);
         
      } else if (selectedAction == "add-jello") {
         if ((c.demoVersion.slice(0,1) == "7") || (c.demoVersion.slice(0,1) == "8")) { 
            // restitution = 0.7, make it a little bouncy for the bullets...
            makeJello({'gridsize':4, 'addToJello':false, 'offset_2d_m':placement_2d_m, 'restitution':0.7});
            
         } else {
            let addToJello;
            if ((c.demoVersion.slice(0,3) == "6.a") || (c.demoVersion.slice(0,3) == "6.d")) {
               messages['help'].newMessage('Jello added for demos 6.a and 6.d will be timed for tangles.', 2.0);
               addToJello = true;
            } else {
               addToJello = false;
            }
            // restitution = 0.0, make it stable for manipulations...
            makeJello({'gridsize':4, 'addToJello':addToJello, 'offset_2d_m':placement_2d_m, 'restitution':0});
         }
       
      } else if (selectedAction == "add-puck-grid") {

         let grid_order = 7;
         let grid_spacing_m = 0.45;
         let startPosition_2d_m = placement_2d_m;
         let v_init_2d_mps = new cP.Vec2D(0.0, 0.0);
         
         for (let i = 1; i <= grid_order; i++) {
            for (let j = 1; j <= grid_order; j++) {
               let delta_2d_m = new cP.Vec2D( i * grid_spacing_m, j * grid_spacing_m);
               let position_2d_m = startPosition_2d_m.add( delta_2d_m);
               new cP.Puck(position_2d_m, v_init_2d_mps, {'radius_m':0.10, 'groupIndex':-1, 'friction':0.0, 'friction_fixed':true});
            }
         }
       
      } else if (selectedAction == "add-npc") {
         if ((c.demoVersion.slice(0,1) == "7") || (c.demoVersion.slice(0,1) == "8")) { 
            // A 2-pin navigation track for a single client.
            pP.makeNPC_OnTwoPins( placement_2d_m);
            
            /*
            let pinRadius = 3;
            let namePinA = 'pin' + (cP.Pin.nameIndex + 1);
            let namePinB = 'pin' + (cP.Pin.nameIndex + 2);
            let pinA = new cP.Pin( placement_2d_m, {'radius_px':pinRadius, 'NPC':true, 'previousPinName':namePinB, 'name':namePinA, 'nextPinName':namePinB});
            let pinB = new cP.Pin( placement_2d_m.add(new cP.Vec2D( 1.0, 1.0)), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':namePinA, 'name':namePinB, 'nextPinName':namePinA});
            let nameForNPC = 'NPC' + (cP.Client.npcIndex + 1);
            new cP.Client({'name':nameForNPC, 'color':'purple'});
            new cP.Puck( placement_2d_m, new cP.Vec2D(0.0, 0.0), {'radius_m':0.30, 'color':'darkblue', 'colorSource':false, 'clientName':nameForNPC, 'linDamp':1.0, 
                                                                  'restitution':0.7, 'restitution_fixed':true, 'hitLimit':20, 'pinName':namePinA, 'rayCast_init_deg':0} );
            */
         } else {
            messages['help'].newMessage('Add drones in 7, 8, or captures of them.', 2.0);
         } 
                                                                  
      } else if (selectedAction == "add-revolute") { 
         addRevoluteJoint();
         
      } else if (selectedAction == "add-chain") { 
         let puck_A, puck_B, joint_AB;
         
         let half_width_m = 0.15;
         let jointFromEnd_m = 0.02 * half_width_m;
         let puckDim = {'shape':'rect', 'half_height_m':0.05, 'half_width_m':half_width_m};
         let velocity_2d_m = new cP.Vec2D(0.0,0.0);
         
         let firstPuck = new cP.Puck( placement_2d_m, velocity_2d_m, puckDim);
         puck_A = firstPuck;
         for (let i = 0; i < 20; i++) {
            puck_B = new cP.Puck( placement_2d_m, velocity_2d_m, puckDim);
            joint_AB = new cP.Joint(puck_A, puck_B, {'jto1_ap_l_2d_m': new cP.Vec2D( half_width_m - jointFromEnd_m, 0.0), 'jto2_ap_l_2d_m': new cP.Vec2D( -half_width_m + jointFromEnd_m, 0.0)}); 
            puck_A = puck_B;
         }
         // join the end of the chain to the first puck.
         joint_AB = new cP.Joint( puck_B, firstPuck, {'jto1_ap_l_2d_m': new cP.Vec2D( half_width_m - jointFromEnd_m, 0.0), 'jto2_ap_l_2d_m': new cP.Vec2D( -half_width_m + jointFromEnd_m, 0.0)});  
      
      } else if (selectedAction == "add-pyramid") {       
         let half_height_m = 0.08;
         let half_width_m = 0.10;
         let delta_x_m = half_width_m * 2.05;
         let delta_y_m = half_height_m * 2.05;
         
         let puckParms = {'shape':'rect', 'half_height_m':half_height_m, 'half_width_m':half_width_m, 'restitution':0.7, 'restitution_fixed':true, 'bullet':false};
         let pyramid_rows = 15;
         
         // left side of pyramid goes at placement_2d_m (i.e. mouse position, if on canvas)
         if ( ! placementByMouse) {
            // special default placement for pyramid
            placement_2d_m = new cP.Vec2D( 1, 1);
         }
         // center of ledge 
         let ledge_position_2d_m =  placement_2d_m.add( new cP.Vec2D( (pyramid_rows - 1) * delta_x_m / 2.0, 0.0));
         let ledgeWall = new cP.Wall( ledge_position_2d_m, {'half_width_m': (pyramid_rows + 1) * delta_x_m / 2.0, 'half_height_m':0.02});
         var topEdge_of_ledgeWalll = ledgeWall.position_2d_m.y + ledgeWall.half_height_m;
         
         let velocity_2d_m = new cP.Vec2D( 0.0, 0.0);
         let x_original_position_m = placement_2d_m.x;
         let y_position_m = topEdge_of_ledgeWalll + half_height_m;
         
         for (let i = 0; i < pyramid_rows; i++) {
            let x_position_m = x_original_position_m + (i * (delta_x_m/2.0) );
            for (let j = i; j < pyramid_rows; j++) {
               let puck_pos_2d_m = new cP.Vec2D( x_position_m, y_position_m);
               new cP.Puck( puck_pos_2d_m, velocity_2d_m.copy(), Object.assign({}, puckParms) );
               x_position_m += delta_x_m;
            }
            y_position_m += delta_y_m;
         }
         
         // add a bullet for destructive purposes
         let bulletLedge_pos_2d_m = ledge_position_2d_m.add( new cP.Vec2D(2.5,0));
         new cP.Wall( bulletLedge_pos_2d_m, {'half_width_m':0.10, 'half_height_m':0.02});
         new cP.Puck( bulletLedge_pos_2d_m.add( new cP.Vec2D(0.0,0.05)), velocity_2d_m.copy(), {'radius_m':0.15, 'bullet':true, 'restitution':0.7, 'restitution_fixed':true} );
      
      } else if (selectedAction == "add-stack") {
         let half_height_m = 0.08;
         let half_width_m = 0.15;
         let delta_y_m = half_height_m * 2.05;
         
         let puckParms = {'shape':'rect', 'half_height_m':half_height_m, 'half_width_m':half_width_m, 'restitution':0.7, 'restitution_fixed':true, 'bullet':false};
         let stack_rows = 15;
         
         // left side of stack goes at placement_2d_m (i.e. mouse position, if on canvas)
         if ( ! placementByMouse) {
            // special default placement for stack
            placement_2d_m = new cP.Vec2D( 3, 1);
         }
         // center of ledge 
         let ledge_position_2d_m =  placement_2d_m;
         let ledgeWall = new cP.Wall( ledge_position_2d_m, {'half_width_m': half_width_m * 1.2, 'half_height_m':0.02});
         var topEdge_of_ledgeWalll = ledgeWall.position_2d_m.y + ledgeWall.half_height_m;
         
         let velocity_2d_m = new cP.Vec2D( 0.0, 0.0);
         let y_position_m = topEdge_of_ledgeWalll + half_height_m;
         
         for (let j = 0; j < stack_rows; j++) {
            let puck_pos_2d_m = new cP.Vec2D( placement_2d_m.x, y_position_m);
            new cP.Puck( puck_pos_2d_m, velocity_2d_m.copy(), Object.assign({}, puckParms) );
            y_position_m += delta_y_m;
         }
      
      /*
      // Examples used in testing:
      } else if (selectedAction == "add-newtons-cradle-1mb") {
         // first, load in the 5h demo (3 moving balls)
         demoStart_fromCapture(5, {'fileName':'demo5h.js'});
         // wait, then run the 1-moving-ball version
         window.setTimeout( function() { 
            newtonsCradle(4,1);
            // capture this to be consistent in the demo world
            saveState({'captureName':'4b-1m'});
         }, 100);
      } else if (selectedAction == "add-newtons-cradle-3mb") {
         demoStart_fromCapture(5, {'fileName':'demo5h.js'});
      */
         
      } else if (selectedAction == "align-selected-pucks") {
         hostMSelect.align();
         
      } else if (selectedAction == "toggle-projectile-forecast") {
         gB.toggleProjectileForecast();
      }
      
      // reset the select element to the placeholder (title) value
      $('#TableActions').val("table-action");
   }
   
   function freeze() {      
      cP.Puck.applyToAll( puck => puck.b2d.SetLinearVelocity( new b2Vec2(0.0,0.0)) );
   }
   function stopRotation() {      
      cP.Puck.applyToAll( puck => puck.b2d.SetAngularVelocity( 0.0) );
   }
   function reverseDirection() {      
      cP.Puck.applyToAll( puck => {
         puck.b2d.SetAngularVelocity( -1 * puck.angularSpeed_rps);
         puck.b2d.SetLinearVelocity( b2Vec2_from_Vec2D( puck.velocity_2d_mps.scaleBy( -1)) );
      });
   }
   
   
   // Functions for Capture and Restore
   
   function json_scrubber( key, value) {
      /*
      Use this function to exclude the b2d objects in the stringify process. 
      Apparently the b2d and rtc objects have circular references that 
      stringify doesn't like. So have to regenerate the b2d objects in the 
      demo area when the json capture is restored. 

      Also have to avoid the client related addons: jet, gun, and shield. 
      These have references back their pucks, this too causes circular issues 
      for stringify. 

      Also remove keys like spo1 and spo2 (in Springs object) mainly to keep 
      the wordiness down; many keys are not needed in the reconstruction 
      process. 

      So be careful here: any key with a name in the OR list of json_scrubber 
      (see if block below) will be excluded from the capture. 
      */
      if ( (key == 'b2d') || (key == 'b2dSensor') || (key == 'rtc') || 
           (key == 'jet') || (key == 'gun') || (key == 'shield') || 
           (key == 'spo1') || (key == 'spo2') || 
           (key == 'jto1') || (key == 'jto2') || 
           (key == 'parsAtBirth') || 
           (key == 'puck') || (key.includes('key_')) || (key.includes('_scaling')) || (key.includes('selectionPoint')) || 
           (key == 'position_2d_px') || (key == 'nonCOM_2d_N') ) {
         return undefined;
      } else {
         return value;
      }
   }
   
   function saveState( pars = {} ) {
      var captureName = setDefault( pars.captureName, null);
      var dataForCleaning = setDefault( pars.dataForCleaning, null);
      var inhibitWriteToCaptureCell = setDefault( pars.inhibitWriteToCaptureCell, false);
      
      var timeString = new Date();
         
      // Use an old capture, that is passed in via dataForCleaning, as the data sources.
      if (dataForCleaning) {
         if ( ! (dataForCleaning.startingPosAndVels)) dataForCleaning.startingPosAndVels = null;
         
         // Add some canvas dimensions if needed.
         if ( ! (dataForCleaning.canvasDimensions)) {
            if (dataForCleaning.demoIndex == 8) {
               dataForCleaning.canvasDimensions = {'width':1250, 'height':950};
            } else {
               dataForCleaning.canvasDimensions = {'width':600, 'height':600};
            }
         }
		 
         var tableState = {'demoIndex':dataForCleaning.demoIndex,
                           'demoVersion':dataForCleaning.demoVersion,
                           'date':timeString.toLocaleString(),
                           'canvasDimensions': {'width':dataForCleaning.canvasDimensions.width, 'height':dataForCleaning.canvasDimensions.height},
                           'gravity':dataForCleaning.gravity,
                           'comSelection':dataForCleaning.comSelection,
                           'fullScreenDemo':dataForCleaning.fullScreenDemo,
                           'lockedAndLoaded':dataForCleaning.lockedAndLoaded,
                           'globalCompositeOperation':dataForCleaning.globalCompositeOperation,
                           'wallMapData':dataForCleaning.wallMapData, 
                           'puckMapData':dataForCleaning.puckMapData, 
                           'pinMapData':dataForCleaning.pinMapData, 
                           'springMapData':dataForCleaning.springMapData,
                           'jointMapData':dataForCleaning.jointMapData,
                           'startingPosAndVels':dataForCleaning.startingPosAndVels,
                           'clients':dataForCleaning.clients};
                           
         if (dataForCleaning.piCalcs) {
            tableState = Object.assign({}, tableState, {'piCalcs':dataForCleaning.piCalcs} );
            if (dataForCleaning.piEngine) {
               tableState = Object.assign({}, tableState, {'piEngine':dataForCleaning.piEngine} );
            }
         } else {
            tableState = Object.assign({}, tableState, {'piCalcs':{}} );
         }
      
      // Get a fresh capture (i.e., using the live stuff as the data source).            
      } else {
         if ( ! inhibitWriteToCaptureCell) {
            if (captureName) {
               c.demoVersion = c.demoVersion + '.' + captureName;
            } else {
               c.demoVersion = c.demoVersion + '.' + Math.floor((Math.random() * 1000) + 1);
            }
            
            // Adjust the highlighting in the plus row
            document.getElementById( c.demoVersion.slice(0,1) + ".a").style = '';
            document.getElementById( c.demoVersion.slice(0,3)).style = 'color:white; background-color:gray; border-style:solid; border-color:black; border-width:4px 0px 4px 0px';
         }
         
         var tableState = {'demoIndex':c.demoIndex,
                           'demoVersion':c.demoVersion,
                           'date':timeString.toLocaleString(),
                           'canvasDimensions': {'width':canvas.width, 'height':canvas.height},
                           'gravity':c.g_ON,
                           'comSelection':dC.comSelection.checked,
                           'fullScreenDemo':c.fullScreenDemo,
                           'lockedAndLoaded':c.lockedAndLoaded,
                           'globalCompositeOperation':ctx.globalCompositeOperation,
                           'wallMapData':aT.wallMap, 
                           'puckMapData':aT.puckMap, 
                           'pinMapData':aT.pinMap, 
                           'springMapData':aT.springMap,
                           'jointMapData':aT.jointMap,
                           'startingPosAndVels':cP.Client.startingPandV,
                           'clients':clients};
                           
         // For demos using the piCalcEngine, add the engine state to the capture.
         if ( ['1.c','1.d','1.e'].includes( demoVersionBase( c.demoVersion)) ) {
            var piCalcs = {};
            piCalcs['clacks'] = c.piCalcs.clacks;
            piCalcs['enabled'] = c.piCalcs.enabled;
            if (c.piCalcs.usePiEngine) {
               cP.PiEngine.state['lastCollidedWithWall']       = piCalcEngine['lastCollidedWithWall'];
               cP.PiEngine.state['atLeastOneCollisionInFrame'] = piCalcEngine['atLeastOneCollisionInFrame'];
               cP.PiEngine.state['nFinerTimeStepFactor'] = piCalcEngine['nFinerTimeStepFactor'];               
               piCalcs['p1_v_max']       = piCalcEngine['p1_v_max'];
               piCalcs['collisionCount'] = piCalcEngine['collisionCount'];
               piCalcs['usePiEngine'] = true;
            } else {
               piCalcs['p1_v_max']       = aT.puckMap['puck1'].vmax;
               piCalcs['collisionCount'] = aT.collisionCount;
               piCalcs['usePiEngine'] = false;
            }
            tableState = Object.assign( {}, tableState, {'piCalcs':piCalcs}, {'piEngine':cP.PiEngine.state} );
         }
      }
      
      // Here you still have full state info. That's because this is before the json_scrubber is called. So if you want to keep
      // something that's about to be scrubbed out, do it here. As an example: the angle of the NPC casting rays,
      // that are in the gun attributes. This can be put into the rayCast_init_deg attribute of the NPC's puck. Then it
      // will be used when the NPC's puck and gun are restored (the gun gets this from its puck).
      for (var p_key in tableState.puckMapData) {
         var puck = tableState.puckMapData[ p_key];
         if ((puck.clientName) && (puck.clientName.slice(0,3) == 'NPC')) {
            puck.rayCast_init_deg = puck.gun.rayCastLine_2d_m.get_angle();
         }
      }
      
      // See comments in the json_scrubber function above.
      var table_JSON = JSON.stringify( tableState, json_scrubber, 3);
      
      // Parsing after JSON.stringify makes a deep copy, with no references back to the original objects. So can delete stuff without
      // mangling the current running demo.
      var tableState_copy = JSON.parse( table_JSON);
      
      // Remove some non-editable puck keys.
      var generalPuckKeys = ['tail','age_ms','ageLimit_ms','radius_px','mass_kg','half_height_px','half_width_px','tempInhibitGForce','spotted',
                             'lowBallFinderCircle_timerLimit_s','lowBallFinderCircle_timer_s','firstClientDirectMove','nameTip_timerLimit_s','nameTip_timer_s'];
      // Remove these from pucks with no client controls.
      var simplePuckKeys =  ['rayCast_init_deg','rayRotationRate_dps','rayCastLineLength_m',
                             'disableJet','noRecoil','bulletAgeLimit_ms','bullet_restitution',
                             'sprDamp_force_2d_N','springOnly_force_2d_N','jet_force_2d_N','impulse_2d_Ns','navSpringOnly_force_2d_N',
                             'poorHealthFraction','whoShotBullet','flash','inComing','flashCount',
                             'hitCount','deleted','clientNameOfShooter'];
      var clientPuckKeys = ['angleLine'];
      var nonNPC_clientPuckKeys = ['disableJet'];
      
      for (var p_key in tableState_copy.puckMapData) {
         var puck = tableState_copy.puckMapData[ p_key];
         
         // Turning off the machSwitch causes the puck to be restored to the captured velocity rather than a specified Mach value.
         if (puck.tailPars) puck.tailPars.machSwitch = false;
         
         // Delete bullet pucks in Puck Popper captures
         if ((tableState_copy.demoIndex == 7) || (tableState_copy.demoIndex == 8)) {
            if (puck.bullet) {
               delete tableState_copy.puckMapData[ p_key];
               continue;
            }
         }
         // Delete keys on pucks:
         // All pucks
         for (var key of generalPuckKeys) {
            delete puck[ key];
         }
         // Simple pucks (no client controls)
         if ( ! puck.clientName) {
            for (var key of simplePuckKeys) {
               delete puck[ key];
            }
         // All client pucks
         } else {
            for (var key of clientPuckKeys) {
               delete puck[ key];
            }
            // All non-NPC client pucks
            if (puck.clientName.slice(0,3) != 'NPC') {
               for (var key of nonNPC_clientPuckKeys) {
                  delete puck[ key];
               }
            }
         }
      }
      
      // Remove some non-editable pin keys.  
      var pinKeys = ['radius_m'];
      for (var pin_key in tableState_copy.pinMapData) {
         var pin = tableState_copy.pinMapData[ pin_key];
         for (var key of pinKeys) {
            delete pin[ key];
         }
      }
      
      // Remove some non-editable wall keys.
      var wallKeys = ['deleted','half_height_px','half_width_px'];  // color
      for (var wall_key in tableState_copy.wallMapData) {
         var wall = tableState_copy.wallMapData[ wall_key];
         for (var key of wallKeys) {
            delete wall[ key];
         }
      }
      
      // Remove some spring keys you won't need.
      var springKeys = ['pinned','p1p2_separation_2d_m','p1p2_separation_m','p1p2_normalized_2d',
                        'spo1_ap_w_2d_m','spo1_ap_w_2d_px','spo2_ap_w_2d_m','spo2_ap_w_2d_px',
                        'selected','softConstraints_setInPars'];
      for (var spring_key in tableState_copy.springMapData) {
         var spring = tableState_copy.springMapData[ spring_key];
         
         // Don't capture the local ap (attach point) for pins.
         if (spring.p1_name.slice(0,3) == "pin") delete spring['spo1_ap_l_2d_m'];
         if (spring.p2_name.slice(0,3) == "pin") delete spring['spo2_ap_l_2d_m'];
         
         for (var key of springKeys) {
            delete spring[ key];
         }
      }
      // Remove some joint keys you won't need.
      var jointKeys = ['jto1_ap_w_2d_m','jto1_ap_w_2d_px','jto2_ap_w_2d_m','jto2_ap_w_2d_px','selected','colorInTransition'];  // color
      for (var joint_key in tableState_copy.jointMapData) {
         var joint = tableState_copy.jointMapData[ joint_key];
         for (var key of jointKeys) { 
            delete joint[ key];
         }
      }
      
      // For client objects, clean off all keys EXCEPT these (i.e. SAVE these): 
      var saveTheseClientKeys = ['color','name','player','nickName','NPC_pin_timer_s','NPC_pin_timer_limit_s'];
      for (var client_key in tableState_copy.clients) {
         var client = tableState_copy.clients[ client_key];
         if (client.name.slice(0,1) == 'u') {
            // Delete network clients...
            delete tableState_copy.clients[ client_key];
         } else {
            // Clean-up everyone else.
            for (var clientKey in client) {
               if ( ! saveTheseClientKeys.includes( clientKey)) {
                  delete client[ clientKey];
               }
            }
         }
      }
      
      // Exit if state data was passed in to be cleaned.
      if (dataForCleaning) return tableState_copy;
      //----------------------------------------------------------------------
      
      // Once again, put it in a string...
      table_JSON = JSON.stringify( tableState_copy, null, 3);
      
      if ( ! inhibitWriteToCaptureCell) {
         // Write the json string to this visible input field.
         dC.json.value = table_JSON;
         // Wait 0.5 seconds, then scroll the input field to the top.
         window.setTimeout( function() { scrollCaptureArea();}, 500);
         
         // Select, copy to clipboard, and then remove focus from the input field.
         dC.json.select();
         document.execCommand('copy');
         window.getSelection().removeAllRanges(); // this is necessary for the blur method to work in MS Edge.
         dC.json.blur();
      }

      return table_JSON;
   }
   
   function clearState() {
      // Reset the capture state...
      dC.json.value = '';
      // Reset the highlight styles in the row below the number buttons.
      var highlightedLinkInPlusRow = document.getElementById( c.demoVersion.slice(0,3));
      var firstLinkInPlusRow = document.getElementById( c.demoVersion.slice(0,1) + ".a");
      if (highlightedLinkInPlusRow) highlightedLinkInPlusRow.style = '';
      if (firstLinkInPlusRow) firstLinkInPlusRow.style = 'color:white; background-color:gray; padding:2px 0px';
   }
   
   function cleanCapture() {
      // Clean up an old capture
      // This can be run from a hidden button (to the right of the clear button) on the index page.
      if (c.demoVersion == '8.a') {
         var state_data = demo_8_fromFile;         
      } else if (c.demoVersion == '6.a') {
         var state_data = demo_6_fromFile;   
      } else {
         if (dC.json.value != "") {
            var state_data = JSON.parse( dC.json.value);
         } else {
            console.log('no capture to clean');
            return;
         }
      }
      
      // first, process (clean) the capture with saveState
      state_data = saveState( {'dataForCleaning':state_data} );
      
      // Special loop for pucks.
      for (var p_key in state_data.puckMapData) {
         var puck = state_data.puckMapData[ p_key];
         
         if (puck.clientName) {
            puck.groupIndex = -puck.name.slice(4) - 1000;
         } else {
            if ((state_data.demoVersion == '3.b') || (state_data.demoVersion == '3.c')) {
               // leave these alone, puck-puck collisions have been inhibited on these pucks.
            } else {
               puck.groupIndex = 0;
            }
         }
      }
      
      // For all the maps.
      var mapList = ['puckMapData','pinMapData','springMapData','jointMapData','wallMapData','clients'];
      for (var map of mapList) {
         for (var key in state_data[ map]) {
            var element = state_data[ map][ key];
            
            delete element['parsAtBirth'];
            delete element['alsoThese'];
            delete element['popsound'];
            
            // Put the alsoThese key at the beginning of the object. Commented this
            // out for now. Could be useful if want to force an attribute to be recognized
            // in the capture.
            //state_data[ map][ key] = Object.assign({'alsoThese':[]}, element);
         }
      }
      
      dC.json.value = JSON.stringify( state_data, null, 3);
      
      // Select, copy to clipboard, and then remove focus from the input field.
      dC.json.select();
      document.execCommand('copy');
      window.getSelection().removeAllRanges(); // this is necessary for the blur method to work in MS Edge.
      dC.json.blur();      
   }
   
   function newBirth( captureObj, type) {
      // Update the birth object (based on the capture state) and use it for restoration.
      var newBirthState = {}, par_list;
      
      // If there's a parameter that is getting into the capture but should be blocked in the birth process:
      var forgetList = {
         'puck': ['position_2d_m','velocity_2d_mps'], // These are explicitly passed to constructor via arguments (so not needed in birth object)
         'wall': ['position_2d_m'],  // Position is passed via arguments. Velocity can be specified in birth object.
         'pin':  ['position_2d_m'],  // Position is passed via arguments. Velocity can be specified in birth object.
         's':    [], // spring
         'j':    [], // joint
         'NPC':  []
      };
      for (var birthParm in captureObj) {
         if (!forgetList[ type].includes( birthParm)) {
            // If this parameter's value is a vector, instantiate it using Vec2D.
            // (Note the check for "null." Null is also an object in javascript, but does not have the hasownProperty method.)
            if ( ((typeof(captureObj[ birthParm]) === 'object') && (captureObj[ birthParm] !== null)) && 
                 ((captureObj[ birthParm].hasOwnProperty('x')) && (captureObj[ birthParm].hasOwnProperty('y'))) ) {
                   
                  newBirthState[ birthParm] = new cP.Vec2D( captureObj[ birthParm].x, captureObj[ birthParm].y);
               
            } else {
               newBirthState[ birthParm] = captureObj[ birthParm];
            }
         }
      }
      
      // For all types, override the default naming process, specify a name in the birth parameters. This gives
      // the new object the name used in the capture object. This is needed in reconstructing 
      // springs (that use the original puck name). This is also needed if pucks are
      // deleted in a jello matrix.
      if (captureObj.name) {
         newBirthState.name = captureObj.name;
      }
      return newBirthState;
   }   
   
   function restoreFromState( state_data) {
      try {
         // return the template that is returned from restoreFromState_main
         return restoreFromState_main( state_data);
      } catch (err) {
         stopit();
         window.alert(c.demoVersion +
                     "\nUnable to restore this capture. " +
                     "\n   Looks like you've been boldly editing the JSON text. Good try!" +
                     "\n   Please refine your edits or start from a new capture." +
                     "\n" +
                     "\n" + err.name +
                     "\nmessage:  " + err.message);
         //clearState();  // clear out the JSON text in the capture cell.
         demoStart(0); // c.demoIndex
      }
   }
      
   function restoreFromState_main( state_data) {
      // Environmental parameters...
      
      // Must do canvas dimensions before setting ctx.globalCompositeOperation.
      if (typeof state_data.canvasDimensions !== "undefined") {
         //canvasDiv.style.width = state_data.canvasDimensions.width + "px";
         canvas.width =          state_data.canvasDimensions.width;
         
         //canvasDiv.style.height = state_data.canvasDimensions.height + "px";
         canvas.height =         state_data.canvasDimensions.height; 
      }
      
      if (state_data.globalCompositeOperation) {
         ctx.globalCompositeOperation = state_data.globalCompositeOperation;
      } else {
         ctx.globalCompositeOperation = 'source-over';
      }
      
      clearCanvas();
      
      if (typeof state_data.demoVersion !== "undefined") {
         c.demoVersion = state_data.demoVersion;
      }
      
      // Message the user if the COM setting is changed by the capture restore.
      if (typeof state_data.comSelection !== "undefined") {
         if ((dC.comSelection.checked) && ( ! state_data.comSelection)) {
            messages['help'].newMessage('Center of mass (COM) selection: [base,yellow]OFF[base]', 3.0);
            
         } else if (( ! dC.comSelection.checked) && (state_data.comSelection)) {
            messages['help'].newMessage('Center of mass (COM) selection: [base,yellow]ON[base]', 3.0);
         }
         dC.comSelection.checked = state_data.comSelection;
         
      } else {
         if ( ! dC.comSelection.checked) {
            messages['help'].newMessage('Center of mass (COM) selection: [base,yellow]ON[base]', 3.0);
            dC.comSelection.checked = true;
            //comSelection_Toggle(null, 2);
         }
      }
      
    
      // Rebuild the walls from the capture data.
      for (var wallName in state_data.wallMapData) {
         // wall references one specific wall (from the captured state)
         var wall = state_data.wallMapData[ wallName];
         // Create the new Wall and add it to the wallMap (via its constructor).
         new cP.Wall( wall.position_2d_m, newBirth( wall, 'wall'));
      }
      // Establish the name of the top leg of the fence (for use by the PiEngine).
      if ((cP.Wall.topFenceLegName == null) && (aT.wallMap['wall1'])) {
         if (aT.wallMap['wall1'].fence) {
            cP.Wall.topFenceLegName = 'wall1';
            //console.log("topFenceLegName=" + cP.Wall.topFenceLegName);
         } else {
            //console.log("wall1 is not part of the fence.");
         }
      } else {
         //console.log("topFenceLegName set by restore");
      }      
      
      // NPC clients...
      for (var clientName in state_data.clients) {
         var client = state_data.clients[ clientName];
         if (clientName.slice(0,3) == 'NPC') {
            new cP.Client( newBirth( client, 'NPC'));
         }
      }
      
      // Rebuild the pins.
      for (var pinName in state_data.pinMapData) {
         // "pin" is one pin (captured state)
         var pin = state_data.pinMapData[ pinName];
         // Create the new Pin and add it to the pinMap (via its constructor).
         new cP.Pin( pin.position_2d_m, newBirth( pin, 'pin'));
      }
      
      // Rebuild the pucks (and the puck map).
      var localHostPuckName = null, networkClientName = null, puckNameForTemplate = null;
      
      for (var p_key in state_data.puckMapData) {
         // puck is a single puck (captured state)
         var puck = state_data.puckMapData[ p_key];
         
         // If there's a puck for the local host, record the name for use in returning a puck template.
         // Also snag a puck name from the network clients as a second option.
         if (puck.clientName == 'local') {
            localHostPuckName = puck.name;
         } else if ((puck.clientName) && (puck.clientName.slice(0,1) == 'u')) {
            networkClientName = puck.name;
         }
         
         // Now create the puck and give it the old name (see the end of the newBirth function).
         // The "Host player" option must be checked to enable the creation of a puck for the local client.
         // Network-client pucks are not recreation here (because it depends on active network clients for assignment).
         if ( (!(puck.bullet && (c.demoIndex == 7 || c.demoIndex == 8))) &&   // NOT a game bullet AND 
              ( (puck.clientName == null) ||                                  // (Regular puck  OR
                (puck.clientName.slice(0,3) == 'NPC') ||                      //  Drone puck    OR
                ((puck.clientName == 'local') && (dC.player.checked)) ) ) {   //  Local host and puck requested)
            
            var newPuck = new cP.Puck( puck.position_2d_m, puck.velocity_2d_mps, newBirth( puck, 'puck'));
            
            if (puck.jello) aT.jelloPucks.push( newPuck);
         }
      }
      
      // For the count-to-pi demos.
      if ( (typeof state_data.piCalcs !== "undefined") && 
           (['1.c','1.d','1.e'].includes( demoVersionBase( c.demoVersion))) ) {
         c.piCalcs.enabled        = state_data.piCalcs.enabled;
         c.piCalcs.clacks         = state_data.piCalcs.clacks;
         c.piCalcs.usePiEngine    = state_data.piCalcs.usePiEngine;
         
         if (state_data.piCalcs.usePiEngine) {
            if (state_data.piEngine) {
               cP.PiEngine.state                = state_data.piEngine;
               cP.PiEngine.state.collisionCount = state_data.piCalcs.collisionCount;
               cP.PiEngine.state.p1_v_max       = state_data.piCalcs.p1_v_max;
            }
         } else {
            // box2d engine
            aT.puckMap['puck1'].vmax = state_data.piCalcs.p1_v_max;
            aT.collisionCount        = state_data.piCalcs.collisionCount;         
         }
      }
      
      // get a reference to a table object using its name
      function tableObj( name) {
         let first3 = name.slice(0,3);
         let tableObj = null;
         if (first3 == "pin") {
            tableObj = aT.pinMap[ name];
         } else if (first3 == "puc") {
            tableObj = aT.puckMap[ name];
         } else if (first3 == "wal") {
            tableObj = aT.wallMap[ name];
         }
         return tableObj;
      }
      
      // Rebuild the springs.
      for (var springName in state_data.springMapData) {
         var theSpring = state_data.springMapData[ springName];
         
         // Don't try to restore navigation springs. Those are created
         // when the NPC pucks are restored.
         if (!theSpring.navigationForNPC && !theSpring.forCursor) {
            /*
            var p1_type = theSpring.p1_name.slice(0,3);
            if (p1_type == "pin") {
               var p1 = aT.pinMap[ theSpring.p1_name];
            } else {
               var p1 = aT.puckMap[ theSpring.p1_name];
            }
            
            var p2_type = theSpring.p2_name.slice(0,3);
            if (p2_type == "pin") {
               var p2 = aT.pinMap[ theSpring.p2_name];
            } else {
               var p2 = aT.puckMap[ theSpring.p2_name];
            }
            */
            let p1 = tableObj( theSpring.p1_name);
            let p2 = tableObj( theSpring.p2_name);
            
            if ((p1) && (p2)) {
               new cP.Spring(p1, p2, newBirth( theSpring, 's'));
            } else {
               console.log('WARNING: Attempting to rebuild a spring with one or both connected objects missing.');
            }
            
         }
      }
      // Rebuild the joints.
      for (var jointName in state_data.jointMapData) {
         var joint = state_data.jointMapData[ jointName];
         
         let to1 = tableObj( joint.jto1_name);
         let to2 = tableObj( joint.jto2_name);
         
         if ((to1) && (to2)) {
            new cP.Joint( to1, to2, newBirth( joint, 'j'));
         } else {
            console.log('WARNING: Attempting to rebuild a joint with one or both connected objects missing.');
         }
      }
      
      // Have this at the end because need the objects instantiated before setting the restitution values
      // in the pucks (side effect of setGravityRelatedParameters)
      c.g_ON = state_data.gravity;
      dC.gravity.checked = c.g_ON;
      setGravityRelatedParameters({});
      
      // Give priority to the host's puck for use as a template. If there was no host puck when
      // the capture was done, the network puck will be used.
      if (localHostPuckName) {
         puckNameForTemplate = localHostPuckName;
      } else {
         puckNameForTemplate = networkClientName;
      }
      
      // Sometimes just want to be sure the user gets the fullscreen view.
      if (state_data.fullScreenDemo) {
         c.fullScreenDemo = true;
      }
      
      // For example, the dandelion demos, turn shooter on for each client.
      if (state_data.lockedAndLoaded ) {
         c.lockedAndLoaded = true;
      }  
      
      // Exit here...
      if (puckNameForTemplate) {
         return state_data.puckMapData[ puckNameForTemplate];
      } else {
         // Looks like a capture was made after host and all network pucks were popped, savage battle.
         // So let's make a puck template from the default pars for the host puck.
         return Object.assign({}, {'position_2d_m':new cP.Vec2D(2.0, 2.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)}, cP.Puck.hostPars);
      } 
   }
   
   
   // Functions in support of the demos ////////////////////////////////////////
   
   function demoVersionBase( demoVersion) {
      var parts = demoVersion.split(".");
      return parts[0] + "." + parts[1];
   }
   
   function scrollDemoHelp( targetID, duration = 500) {
      c.currentScrollTarget = targetID;
      var container = $('#helpScroller');
      var scrollTo  = $(targetID);
      var tweak_px = -6;
      if (targetID == 'scroll-to-very-top') {
         container.animate( {scrollTop: 0}, duration);
      } else {
         container.animate( {scrollTop: scrollTo.offset().top - container.offset().top + container.scrollTop() + tweak_px}, duration );
      }
   }
   
   function scrollCaptureArea() {
      dC.json.scrollTop = 30; 
      dC.json.scrollLeft = 130;
   }
   
   // Editor help toggle
   function openDemoHelp() {
      // Not using this anymore. A bit confusing. Might bring it back.
      
      if (dC.multiplayer.checked) {
         $('#chkMultiplayer').trigger('click');
      }
   
      toggleElementDisplay('outline1','block');
      
      toggleSpanValue('moreOrLess','More','Less');
      toggleSpanValue('moreOrLess2','More','Less');
      
      scrollDemoHelp('#editorMark');
   }
   
   function resetRestitutionAndFrictionParameters() {
      cP.Puck.restitution_gOn = cP.Puck.restitution_default_gOn;
      cP.Puck.friction_gOn = cP.Puck.friction_default_gOn;
      
      cP.Puck.restitution_gOff = cP.Puck.restitution_default_gOff;
      cP.Puck.friction_gOff = cP.Puck.friction_default_gOff;
   }
   
   function setGravityRelatedParameters( pars) {
      var showMessage = setDefault( pars.showMessage, false);
   
      if (c.g_ON) {
         // Box2D velocityThreshold setting is needed for settling stacks of pucks.
         Box2D.Common.b2Settings.b2_velocityThreshold = 1.0; // 1.0
         cP.Puck.g_2d_mps2 = new cP.Vec2D(0.0, -c.g_mps2); // module-level
         var restitution = cP.Puck.restitution_gOn;
         var friction =    cP.Puck.friction_gOn;
      } else {
         // But here, with no gravity, it's better to turn the velocityThreshold setting off 
         // so pucks don't stick to walls.
         Box2D.Common.b2Settings.b2_velocityThreshold = 0.0; // 0.0
         cP.Puck.g_2d_mps2 = new cP.Vec2D(0.0, 0.0); // module-level
         var restitution = cP.Puck.restitution_gOff;
         var friction =    cP.Puck.friction_gOff;
      }
      if (showMessage) {
         messages['help'].newMessage('Gravity = [base,yellow]' + cP.Puck.g_2d_mps2.y + '[base]', 1.0);
      }
      
      // If there are some existing pucks on the table:
      // If not fixed, set restitution and friction properties.
      cP.Puck.applyToAll( puck => {
         if (!puck.restitution_fixed) {
            puck.b2d.m_fixtureList.m_restitution = restitution;
            puck.restitution = restitution;
         }
         if (!puck.friction_fixed) {
            puck.b2d.m_fixtureList.m_friction    = friction;  
            puck.friction = friction;
         }
      });
   }
   
   function em( px) {
      // Convert to em units based on a font-size of 16px.
      return px/16.0;
   }
   
   function getCanvasDimensions() {
      // This (revealed) function is needed to share these canvas parameters with other modules.
      // The canvas element cannot be revealed directly because of the page-load delay before its characteristics are established. 
      return {'width':canvas.width, 'height':canvas.height};
   }
   
   function fullScreenState( mode = 'get') {
      // Get or set the fullscreen state. This function is revealed.
      if (mode == 'get') {
         return c.fullScreenState;
      } else if (mode == 'on') {
         c.fullScreenState = true;
      } else if (mode == 'off') {
         c.fullScreenState = false;
      } else {
         console.log('from fullScreenState');
      }
   }
   
   function adjustSizeOfChatDiv( mode) {
      // This is used for both the host and client pages. Any calls to getElementById
      // will return a null for elements not found on that page.
      
      if (mode == 'mobile') mode = 'small';
      
      // Input fields
      dC.nodeServer = document.getElementById('nodeServer');
      dC.roomName = document.getElementById('roomName');
      dC.inputField = document.getElementById('inputField');
      
      // The two divs that toggle
      dC.multiPlayer = document.getElementById('multiPlayer');
      dC.ttcIntro = document.getElementById('ttcIntro');
      
      // connectionCanvas is only on the client page.
      dC.connectionCanvas = document.getElementById('connectionCanvas');
      
      var divW_Large = em(540);
      var divW_Small = em(540-118);

      var tweek = -8;
      var nodeServer_Large = em(332+tweek);
      var roomName_Large   = em( 70+0);
      var inputField_Large = em(534+tweek); //536
      var connectionCanvas_Large_px = 518+tweek;  //518
      
      var shrink_px = 141;
      var shrink = em( shrink_px);
      
      var nodeServer_Small = nodeServer_Large - shrink;
      var roomName_Small   = roomName_Large   -  em(0);
      var inputField_Small = inputField_Large - shrink;
      var connectionCanvas_Small_px = connectionCanvas_Large_px - 117;
      
      if (mode == 'small') {
         dC.nodeServer.style.width = (nodeServer_Small) + 'em';
         dC.roomName.style.width   = (roomName_Small  ) + 'em';
         dC.inputField.style.width = (inputField_Small) + 'em';
         if (dC.connectionCanvas) {
            dC.connectionCanvas.width = connectionCanvas_Small_px;
            dC.connectionCanvas.height = 15;
            hC.refresh_P2P_indicator({'mode':'p2p','context':'sizeAdjust'});
         }
         
         dC.ttcIntro.style.maxWidth    = divW_Small + 'em';
         dC.ttcIntro.style.minWidth    = divW_Small + 'em';
         
         dC.multiPlayer.style.maxWidth = divW_Small + 'em';
         dC.multiPlayer.style.minWidth = divW_Small + 'em';
         
      } else {
         dC.nodeServer.style.width = (nodeServer_Large) + 'em';
         dC.roomName.style.width   = (roomName_Large  ) + 'em';
         dC.inputField.style.width = (inputField_Large) + 'em'; 
         if (dC.connectionCanvas) {
            dC.connectionCanvas.width = connectionCanvas_Large_px;
            dC.connectionCanvas.height = 15;
            hC.refresh_P2P_indicator({'mode':'p2p','context':'sizeAdjust'});
         }

         dC.ttcIntro.style.maxWidth    = divW_Large + 'em';
         dC.ttcIntro.style.minWidth    = divW_Large + 'em';
         
         dC.multiPlayer.style.maxWidth = divW_Large + 'em';
         dC.multiPlayer.style.minWidth = divW_Large + 'em';
      }
   }    

   function makeJello( pars) {
      var pinned = setDefault( pars.pinned, false);
      var gridsize = setDefault( pars.gridsize, 4);
      var addToJello = setDefault( pars.addToJello, true);
      var offset_2d_m = setDefault( pars.offset_2d_m, new cP.Vec2D(2.0, 2.0));
      var restitution = setDefault( pars.restitution, 0.7);

      var spacing_factor_m = 0.9;
      
      var v_init_2d_mps = new cP.Vec2D(0.0, 0.0);
      
      var puckParms = {'radius_m':0.20, 'density':5.0, 'jello':true, 'restitution':restitution, 'restitution_fixed':true};
      
      var springParms = {
         'unstretched_width_m': 0.07,
         'strength_Npm': 350.0,          
         'length_m': spacing_factor_m * 1.0,
         'damper_Ns2pm2': 5.0};

      // a local jello array for baking the jello
      let jelloPucks = [];

      // Grid of pucks.
      for (var j = 0; j < gridsize; j++) {
         for (var k = 0; k < gridsize; k++) {
            if ((j==2) && (k==2)) {
               puckParms.color = "orange";
            } else {
               puckParms.color = undefined;  // use default
            }
            var pos_2d_m = new cP.Vec2D( spacing_factor_m * j, spacing_factor_m * k);
            pos_2d_m.addTo( offset_2d_m);
            jelloPucks.push( new cP.Puck( Object.assign({}, pos_2d_m), Object.assign({}, v_init_2d_mps), Object.assign({}, puckParms)));
         }
      }
      // Horizontal springs (between neighbors)
      for (var m = 0; m < gridsize*(gridsize-1); m++) {
         springParms.color = "blue";
         // Note: Object.assign is used here to make a copy of the springParms object (mutable). This avoids the multiple reference to springParms
         // and any associated mutation side effects (from this and the following color changes) when the state is captured.
         new cP.Spring(jelloPucks[m], jelloPucks[m+gridsize], Object.assign({}, springParms));
      }
      // Vertical springs
      for (var m = 0; m < gridsize-1; m++) {
         for (var n = 0; n < gridsize; n++) {
            var o_index = m + (n * gridsize);
            springParms.color = "blue";
            new cP.Spring(jelloPucks[o_index], jelloPucks[o_index+1], Object.assign({}, springParms));
         }
      }
      // Diagonal springs (yellow)
      for (var m = 0; m < gridsize-1; m++) {
         for (var n = 1; n < gridsize; n++) {
            var o_index = m + (n * gridsize);
            springParms.color = "yellow";
            springParms.length_m = spacing_factor_m * 1.41;  // A diagonal
            new cP.Spring(jelloPucks[o_index], jelloPucks[o_index-(gridsize-1)], Object.assign({}, springParms));
         }
      }
      // Diagonal springs (perpendicular to the other diagonals)
      for (var m = 0; m < gridsize-1; m++) {
         for (var n = 0; n < gridsize-1; n++) {
            var o_index = m + (n * gridsize);
            springParms.color = "yellow";
            springParms.length_m = spacing_factor_m * 1.41; // A diagonal
            new cP.Spring(jelloPucks[o_index], jelloPucks[o_index+(gridsize+1)], Object.assign({}, springParms));
         }
      }
      
      // Add two pinned springs.
      if (pinned) {
         var corner_puck = (gridsize * gridsize) - 1;
         new cP.Spring(jelloPucks[ 0], new cP.Pin( new cP.Vec2D( 0.5, 0.5), {radius_px:4}), {strength_Npm:800.0, unstretched_width_m:0.3, color:'brown',damper_Ns2pm2:5.0});
         new cP.Spring(jelloPucks[ corner_puck], new cP.Pin( new cP.Vec2D( 9.0, 9.0), {radius_px:4}), {strength_Npm:800.0, unstretched_width_m:0.3, color:'brown',damper_Ns2pm2:5.0});
      }
      
      // Add this new jello to the global jello array. This allows multiple pieces of jello to
      // be used in the 6a and 6d detangle games.
      if (addToJello) {
         for (var j = 0, len = jelloPucks.length; j < len; j++) {
            aT.jelloPucks.push( jelloPucks[j]);
         }
      }
   }
   
   function checkForJelloTangle() {
      // Determine if tangled by looking for balls that are fairly close to 
      // each other. This does not require puck contact to detect a tangle.
      
      // A looping structure that avoids self reference and repeated puck-otherpuck references.
      var stillTangled = false;
      for (var j = 0, len = aT.jelloPucks.length; j < len; j++) {
         for (var k = j+1; k < len; k++) {
            // Check distance between j and k pucks.
            var diff_2d_m = aT.jelloPucks[j].position_2d_m.subtract( aT.jelloPucks[k].position_2d_m);
            
            // Square of the vector length.
            var lenSquared = diff_2d_m.length_squared();
            
            // Make the separation test a little more than the sum of the radii (add 30% of the radius of the smaller puck).
            // Then square it for comparison with the length squared.
            var radiiSum_m = aT.jelloPucks[j].radius_m + aT.jelloPucks[k].radius_m;
            var minRadius_m = Math.min( aT.jelloPucks[j].radius_m, aT.jelloPucks[k].radius_m )
            var separation_check = Math.pow(radiiSum_m + (minRadius_m * 0.30), 2);
            
            if (lenSquared < separation_check) {
               // This one is too close to be in a non-tangled jello block.
               stillTangled = true;
               c.jello.tangleTimer_s += c.deltaT_s;
               j = k = 10000; // break out of the two loops.
            }
         }
      }
      
      messages['jelloTimer'].newMessage( c.jello.tangleTimer_s.toFixed(2), 0.2);
      
      if (!stillTangled) {
         // Get a timestamp for use in verification.
         if (!c.jello.verifyingDeTangle) {
            c.jello.timerAtDetangle_s = c.jello.tangleTimer_s;
         }
         // Wait 1.000 seconds and verify (that there has been no timer change).
         if (!c.jello.reported && !c.jello.verifyingDeTangle) {
            c.jello.verifyingDeTangle = true;
            window.setTimeout( function() { 
               // If the timer hasn't advanced, must still be detangled.
               if (c.jello.tangleTimer_s == c.jello.timerAtDetangle_s) {
                  if (!c.jello.reported) {
                     
                     // leaderboard stuff
                     cP.Client.applyToAll( client => { 
                        client.addScoreToSummary( c.jello.tangleTimer_s.toFixed(2), c.demoIndex, pP.getNpcSleepUsage());
                     });
                     reportGameResults();
                     // Send a score for each human player to the leaderboard. Build leaderboard report at the end.
                     submitScoresThenReport();
                     // Open up the multi-player panel so you can see the leader-board report.
                     if (!dC.multiplayer.checked) {  
                        $('#chkMultiplayer').trigger('click');
                     }
                     // Make sure this gets reported only once (per demo #6 start).
                     c.jello.reported = true;
                     messages['win'].newMessage("That's better. Thank you.", 3.5);
                     clients['local'].winCount += 1;
                  }
               } else {
                  console.log('not sustainably detangled...');
               }
               c.jello.verifyingDeTangle = false;
            }, 1000);
         }
      }
   }
   
   function setNickNameWithoutConnecting() {
      var nickName = hC.checkForNickName('normal');
      if (nickName.status == 'too long') {
         hC.displayMessage('Nicknames must have 10 characters or less. Please shorten it and then try again.');
      } else if (nickName.value) {
         hC.displayMessage('Your nickname is ' + nickName.value + '.');
      }
   }
   
   function leaderBoardReport( lbResp, gameVersion) {
      c.leaderBoardIndex += 1;
      var scoreCell_id = 'scoresCell' + c.leaderBoardIndex;
      var timeCell_id = 'timesCell' + c.leaderBoardIndex;
      var scoreOrTime_id = 'scoreOrTime' + c.leaderBoardIndex;
      
      // Simplify the reporting for Jello Madness because there is only the time-based result (no scoring result).
      if (c.demoIndex == 6) {
         var rankString = "";
         rankString = "On a time basis, " + lbResp.userName + " placed " + lbResp.timeSortedResults.userRank + ' of ' + lbResp.timeSortedResults.scoreCount + 
                             ", " + lbResp.timeSortedResults.winTime + " seconds.</br><br class='score'>";
         var leaderBoardReportHTML = "Leader Board Report: " + gameVersion + "</br><br class='score'>" + rankString;
         
      } else {
         if (lbResp.userRank != 'mouse or npcSleep usage') {
            var rankString = "Highest human scorer, " + lbResp.userName + ', placed ' + lbResp.userRank + ' of ' + lbResp.scoreCount + ' with a score of ' + lbResp.userScore + ". ";
            if (lbResp.timeSortedResults.winTime != '') {
               rankString += "On a time basis, placed " + lbResp.timeSortedResults.userRank + ' of ' + lbResp.timeSortedResults.scoreCount + 
                             ", " + lbResp.timeSortedResults.winTime + " seconds.";
            }
         } else {
            var rankString = "Highest human scorer, " + lbResp.userName + ', scored ' + lbResp.userScore + " (mouse or npc-sleep used).";
         }
         
         rankString += "</br><br class='score'>";
         // Build the toggle link that swaps the time-sorted and score-sorted tables.
         // (Note the use of the escape \ to get three levels of quotations in the following string.) 
         var scoreOrTime_string = (c.demoIndex == 3) ? 'time':'score';
         var leaderBoardReportHTML = "Leader Board Report: " + gameVersion + "&nbsp;&nbsp;&nbsp;(" + 
              "<a title = 'toggle between low-time and high-score based queries' " + 
                 "onclick=\"gW.toggleElementDisplay('" + timeCell_id +  "','block'); " + 
                           "gW.toggleElementDisplay('" + scoreCell_id + "','block'); " +
                           "gW.toggleSpanValue('" + scoreOrTime_id + "','time','score');\">" + 
              "<span id='" + scoreOrTime_id + "'>" + scoreOrTime_string + "</span></a>)" + 
              "</br><br class='score'>" + rankString;
      }
      
      // Add the tables
      var scoreTable = leaderBoardTable( "score",   lbResp,                   gameVersion);
      var timeTable  = leaderBoardTable( "winTime", lbResp.timeSortedResults, gameVersion);
      
      // For pool game, make the score-sorted table the default.
      if (c.demoIndex == 3) {
         leaderBoardReportHTML += 
            "<table><tr>" + 
            "<td id='" + scoreCell_id + "' style='vertical-align:text-top; display:block'>" + scoreTable + "</td>" + 
            "<td id='" + timeCell_id +  "' style='display:none'>" + timeTable + "</td>" + 
            "</tr></table>";
      } else {
         leaderBoardReportHTML += 
            "<table><tr>" + 
            "<td id='" + scoreCell_id + "' style='display:none'>" + scoreTable + "</td>" + 
            "<td id='" + timeCell_id +  "' style='vertical-align:text-top; display:block'>" + timeTable + "</td>" + 
            "</tr></table>";
      }
      
      // Find the most recent game report element (in the chat panel).
      var gameReportElement = document.getElementById("gR" + hC.gb.gameReportCounter);
      // Append the leader-board report to the game report.
      gameReportElement.innerHTML = gameReportElement.innerHTML + "<br>" + leaderBoardReportHTML;
      
      // Send this, the combo of the game summary and leader-board report, to everyone else in the 
      // room so they can see it in their chat panel.
      hC.chatToNonHostPlayers( gameReportElement.innerHTML);
   }
   
   function checkIfInGameTable( userName, winTime, userScore, index) {
      // This compares one row from the leaderboard report to each row in the game table.
      var inTable = false;
      for (let scoreRecord of cP.Client.scoreSummary) {
         if ((scoreRecord['name'] == userName) && (scoreRecord['winner'] == winTime) && (scoreRecord['score'] == userScore) && (scoreRecord['randomIndex'] == index)) {
            inTable = true;
         } 
      }
      return inTable;
   }
   
   function scoreTipPool() {
      if (c.demoVersion.includes('8ball')) {
         return c.scoreTipPool_8ball;
      } else if (c.demoVersion.includes('9ball')) {
         return c.scoreTipPool_9ball;
      } else if (c.demoVersion.includes('rotation')) {
         return c.scoreTipPool_rotation;
      }
   }
   
   function leaderBoardTable( mode, lbResp, gameVersion) {
      var rowIndex = 1;
      
      // If no records in the report, return with this simple warning.
      if (lbResp.users.length < 1) return "(no " + mode + " records)";
      
      var colHighLightStyle = "style='background-color:#ffffef;'"; // #FFFFFF #e2e2b7 #f7f7d7 #f9f9e5 #ffffef
      var rowHighLightStyle = "style='background-color:darkgray; color:white'";
      if (mode == 'score') {
         var style_score = colHighLightStyle;
         var style_winTime = "";
         var tableClass = "score";
      } else {
         var style_winTime = colHighLightStyle;
         var style_score = "";
         var tableClass = "score";
      }
            
      // Ghost-ball pool
      if (c.demoIndex == 3) {
         var tableString = "<table class='" + tableClass + "'><tr align='right'>" +
            "<td class='leaderboardHeader'></td>" +
            "<td class='leaderboardHeader' title='client name \n or \nnickname (client name)'>name</td>" +
            "<td class='leaderboardHeader' title='" +c.timeTipPool+ "' " +style_winTime+ ">time</td>" +
            "<td class='leaderboardHeader' title='" +scoreTipPool()+ "' " +style_score+ ">score</td>" +
            "<td class='leaderboardHeader' title='monitor frames per second'>fps</td>" +
            "<td class='leaderboardHeader' title='inverse of the physics timestep'>ipt</td>" +
            "<td class='leaderboardHeader' title='virtual gamepad was used during game'>vgp</td>" +
            "</tr>";
      // Jello Madness      
      } else if (c.demoIndex == 6) {
         var tableString = "<table class='" + tableClass + "'><tr align='right'>" +
            "<td class='leaderboardHeader'></td>" +
            "<td class='leaderboardHeader' title='client name \n or \nnickname (client name)'>name</td>" +
            "<td class='leaderboardHeader' title='time (seconds) to untangle the jello (separate the pucks)' " +style_winTime+ ">time</td>" +
            "<td class='leaderboardHeader' title='human players'>p</td>" +
            "<td class='leaderboardHeader' title='monitor frames per second'>fps</td>" +
            "<td class='leaderboardHeader' title='inverse of the physics timestep'>ipt</td>" +
            "</tr>";
      // Puck Popper      
      } else {
         var tableString = "<table class='" + tableClass + "'><tr align='right'>" +
            "<td class='leaderboardHeader'></td>" +
            "<td class='leaderboardHeader' title='client name \n or \nnickname (client name)'>name</td>" +
            "<td class='leaderboardHeader' title='time (seconds) to win game (last puck standing)' " +style_winTime+ ">time</td>" +
            "<td class='leaderboardHeader' title='" +c.scoreTip+ "' " +style_score+ ">score</td>" +
            "<td class='leaderboardHeader' title='human players'>p</td>" +
            "<td class='leaderboardHeader' title='drones'>d</td>" +
            "<td class='leaderboardHeader' title='monitor frames per second'>fps</td>" +
            "<td class='leaderboardHeader' title='inverse of the physics timestep'>ipt</td>" +
            "<td class='leaderboardHeader' title='virtual gamepad was used during game'>vgp</td>" +
            "<td class='leaderboardHeader' title='friendly fire was prevented during game'>nff</td>" +
            "</tr>";
      }
      
      for (let score of lbResp.users) {
         // Highlight each row in the leader-board report that matches any row in the game result report.
         if ( checkIfInGameTable( score['userName'], score['winTime'], score['score'], score['index']) ) {
            var rowStyle = rowHighLightStyle;
            var style_score_td = "";
            var style_winTime_td = "";
         } else {
            var rowStyle = "";
            var style_score_td = style_score;
            var style_winTime_td = style_winTime;
         }
         
         if (typeof score['winTime'] == 'number') {
            if (mode == 'score') {
               var timeResult = score['winTime'].toFixed(2);
            } else {
               var timeResult = score['winTime'].toFixed(2);
            }
         } else {
            var timeResult = score['winTime'];
         }
         
         // Ghost-ball pool
         if (c.demoIndex == 3) {
            tableString += "<tr align='right' " + rowStyle + ">" + 
               "<td class='leaderboardIndex'>" + rowIndex + "</td>" +
               "<td class='leaderboardName'                        >" + score['userName'].replace('(host)','(h)') + "</td>" +
               "<td class='leaderboardScore' " +style_winTime_td+ ">" + timeResult +                                "</td>" +
               "<td class='leaderboardScore' " +style_score_td+   ">" + score['score'] +                            "</td>" +
               "<td class='leaderboardScore'                       >" + score['frMonitor'] + "</td>" +
               "<td class='leaderboardScore'                       >" + score['hzPhysics'] + "</td>" +
               "<td class='leaderboardScore'                       >" + score['virtualGamePad'] + "</td>" +
               "</tr>";
         // Jello Madness
         } else if (c.demoIndex == 6) {
            tableString += "<tr align='right' " + rowStyle + ">" + 
               "<td class='leaderboardIndex'>" + rowIndex + "</td>" +
               "<td class='leaderboardName'                        >" + score['userName'].replace('(host)','(h)') + "</td>" +
               "<td class='leaderboardScore' " +style_winTime_td+ ">" + timeResult +                                "</td>" +
               "<td class='leaderboardScore'                       >" + score['nPeople'] + "</td>" +
               "<td class='leaderboardScore'                       >" + score['frMonitor'] + "</td>" +
               "<td class='leaderboardScore'                       >" + score['hzPhysics'] + "</td>" +
               "</tr>";
         // Puck Popper
         } else {
            tableString += "<tr align='right' " + rowStyle + ">" + 
               "<td class='leaderboardIndex'>" + rowIndex + "</td>" +
               "<td class='leaderboardName'                        >" + score['userName'].replace('(host)','(h)') + "</td>" +
               "<td class='leaderboardScore' " +style_winTime_td+ ">" + timeResult +                                "</td>" +
               "<td class='leaderboardScore' " +style_score_td+   ">" + score['score'] +                            "</td>" +
               "<td class='leaderboardScore'                       >" + score['nPeople'] + "</td>" +
               "<td class='leaderboardScore'                       >" + score['nDrones'] + "</td>" +
               "<td class='leaderboardScore'                       >" + score['frMonitor'] + "</td>" +
               "<td class='leaderboardScore'                       >" + score['hzPhysics'] + "</td>" +
               "<td class='leaderboardScore'                       >" + score['virtualGamePad'] + "</td>" +
               "<td class='leaderboardScore'                       >" + score['noFriendlyFire'] + "</td>" +
               "</tr>";
         }
         rowIndex += 1;
      }
      tableString += "</table>";
      return tableString;
   }
   
   function logEntry( eventDescription, mode='normal') {
      // If this page is coming from the production server...
      var pageURL = window.location.href;
      if (pageURL.includes("timetocode")) {
         var sheetURL = 'https://script.google.com/macros/s/AKfycbymaDOxbOAtZAzgxPwm6yIvWG8Euw8jcHM1weyQ_caVSL0BkBI/exec';
         // AJAX
         var xhttp = new XMLHttpRequest();
         xhttp.open('GET', sheetURL + '?mode=' + mode + '&eventDesc=' + eventDescription, true);
         xhttp.send();
      }
   }
   
   function submitScoresThenReport() {
      var nR = 0;
      var peopleClients = [];
      // Define the spreadsheet function within this submitScoresThenReport scope so it has access to nR
      // and peopleClients.
      function sendScoreToSpreadSheet( mode, userName, userScore, gameVersion, winner, mouse, npcSleep, n_people, n_drones, frameRate_monitor, frameRate_physics, virtualGamePad, noFriendlyFire, index) {
         // The "Deployment ID" in this URL is found via the "Tools/Script editor/Select a project..." interface for the spreadsheet. Pick "Manage deployments" from the "Deploy" select element, upper right.
         // The "Deployment ID" does not give general access to the account. It only allows the web user to submit parameters to the doGet function in the spreadsheet project's script.
         var sheetURL = 'https://script.google.com/macros/s/AKfycbz2DWA7VNas0M4ZwIADjPBSxF9SLqX64PxnwpF-bbM0xECDZrhS/exec';
         
         // AJAX
         var xhttp = new XMLHttpRequest();
         xhttp.open('GET', sheetURL + '?mode=' + mode + 
                                      '&userName=' + userName + '&score=' + userScore +  '&gameVersion=' + gameVersion + 
                                      '&winTime=' + winner +    '&mouse=' + mouse +      '&npcSleep=' + npcSleep +
                                      '&nPeople=' + n_people +  '&nDrones=' + n_drones + '&frMonitor=' + frameRate_monitor + '&hzPhysics=' + frameRate_physics + 
                                      '&virtualGamePad=' + virtualGamePad + '&noFriendlyFire=' + noFriendlyFire + '&index=' + index, true);
         xhttp.send();
         xhttp.onreadystatechange = function () {
            // If there is a response from the spreadsheet:
            if (this.readyState == 4 && this.status == 200) {
               // lbResp is short for leaderBoardResponse
               var lbResp = JSON.parse( this.responseText);
               
               if (lbResp.result == 'report') {
                  /*
                  // useful for testing:
                  console.log('You, ' + lbResp.userID + ', placed ' + lbResp.userRank + ' of ' + lbResp.scoreCount + ' with a score of ' + lbResp.userScore);
                  for (var i = 0; i < lbResp.users.length; i++) {
                     // Convert the date so can display it.
                     var recordDate = new Date(lbResp.users[i].date);
                     var recordDateString = recordDate.getDate() +'/'+ (recordDate.getMonth() + 1) +'/'+ recordDate.getFullYear() +' '+ recordDate.getHours() +':'+ recordDate.getMinutes();         
                     console.log(recordDateString + ', ' + lbResp.users[i].id + ', ' + lbResp.users[i].score);
                  } 
                  */
                  
                  // Assemble the html needed to display the leaderboard query results in the chat panel.
                  leaderBoardReport( lbResp, gameVersion);
                  
               } else {
                  console.log( lbResp.result);
                  if (lbResp.error) console.log( lbResp.error);
               }
               
               // Keep (recursively) sending data until the last score (highest), ask for a report for that last one. 
               nR += 1;
               console.log('rC='+nR);
               
               if (nR < n_people-1) {
                  // Make another non-report entry
                  sendScoreToSpreadSheet( 'noReport', peopleClients[ nR]['name'], peopleClients[ nR]['score'], c.demoVersion, 
                                          peopleClients[ nR]['winner'], peopleClients[ nR]['mouse'], peopleClients[ nR]['npcSleep'], 
                                          n_people, n_drones, frameRate_monitor, frameRate_physics, peopleClients[ nR]['virtualGamePad'], noFriendlyFire, peopleClients[ nR]['randomIndex']);
                  
               } else if (nR == n_people-1) {
                  // Do a final submission, and ask for a report (see first parameter) from the spreadsheet this time.
                  sendScoreToSpreadSheet( 'report',   peopleClients[ nR]['name'], peopleClients[ nR]['score'], c.demoVersion,
                                          peopleClients[ nR]['winner'], peopleClients[ nR]['mouse'], peopleClients[ nR]['npcSleep'], 
                                          n_people, n_drones, frameRate_monitor, frameRate_physics, peopleClients[ nR]['virtualGamePad'], noFriendlyFire, peopleClients[ nR]['randomIndex']);
               }
            }
         }
      }
      
      // Ascending sort (this way the report gets issued on the highest score, last one.)
      cP.Client.scoreSummary.sort((a, b) => a['score'] - b['score']);
      
      // Make a subset of the scores to only include real people.
      for (let score of cP.Client.scoreSummary) {
         // Filter out the NPC pucks here.
         if ( ! score['name'].includes('NPC')) {
            peopleClients.push( score);
         }
      }
      var n_people = peopleClients.length;
      var n_drones = cP.Client.scoreSummary.length - n_people;
      var frameRate_monitor = dC.fps.innerHTML; //current observed refresh rate of the monitor
      var frameRate_physics = $('#FrameRate').val(); //timestep for engine
      var noFriendlyFire = (dC.friendlyFire.checked) ? '':'x'; 
      
      // Recursively send the scores. If only one player, go right to 'report' mode.
      if (n_people > 0) {
         var reportMode = (n_people == 1) ? 'report':'noReport'; 
         sendScoreToSpreadSheet( reportMode, peopleClients[0]['name'], peopleClients[0]['score'], c.demoVersion, 
                                             peopleClients[0]['winner'], peopleClients[0]['mouse'], peopleClients[0]['npcSleep'], 
                                             n_people, n_drones, frameRate_monitor, frameRate_physics, peopleClients[0]['virtualGamePad'], noFriendlyFire, peopleClients[0]['randomIndex']);
      }
   }
   
   function reportGameResults() {
      // Delete the old help link in leaderboard (in the prior report) before making one in the current report.
      $( ".helpLinkFromLB" ).remove();
      var firstLine = "Game Summary: " + c.demoVersion + 
                      "<span class='helpLinkFromLB' style='float:right'>(<a title='click or use the m key to toggle between help and leaderboard'" +
                      "onclick= \" $('#chkMultiplayer').trigger('click'); \" >help</a>)</span> </br><br class='score'>";
      
      // Jello Madness
      if (c.demoIndex == 6) {
         var summaryString = firstLine + 
            "<table class='score'><tr align='right'>" +
            "<td class='scoreHeader' title='client name \n or \nnickname (client name)'>name</td>" +
            "<td class='scoreHeader' title='time (seconds) to untangle the jello (separate the pucks)'>time</td>" +
            "</tr>";
         for (let score of cP.Client.scoreSummary) {
            summaryString += "<tr align='right'>" + 
            "<td class='score'>" + score['name']     + "</td>" + 
            "<td class='score'>" + score['winner']   + "</td>" + 
            "</tr>";
         }
      // Ghost Pool
      } else if (c.demoIndex == 3) {
         var summaryString = firstLine + 
            "<table class='score'><tr align='right'>" +
            "<td class='scoreHeader' title='client name \n or \nnickname (client name)'>name</td>" +
            "<td class='scoreHeader' title='" +c.timeTipPool+ "'>time</td>" +
            "<td class='scoreHeader' title='" +scoreTipPool()+ "'>score</td>" +
            "<td class='scoreHeader' title='virtual gamepad used during game'>vgp</td>" + 
            "</tr>";
         for (let score of cP.Client.scoreSummary) {
            summaryString += "<tr align='right'>" + 
            "<td class='score'>" + score['name']     + "</td>" + 
            "<td class='score'>" + score['winner']   + "</td>" + 
            "<td class='score'>" + score['score']    + "</td>" + 
            "<td class='score'>" + score['virtualGamePad'] + "</td>" +
            "</tr>";
         }
      // Puck Popper
      } else {
         cP.Client.scoreSummary.sort((a, b) => b['score'] - a['score']);
         var summaryString = firstLine + 
            "<table class='score'><tr align='right'>" +
            "<td class='scoreHeader' title='client name \n or \nnickname (client name)'>name</td>" +
            "<td class='scoreHeader' title='time (seconds) to win game (last puck standing)'>time</td>" +
            "<td class='scoreHeader' title='" +c.scoreTip+ "'>score</td>" +
            "<td class='scoreHeader' title='mouse usage in the canvas area'>m</td>" +
            "<td class='scoreHeader' title='NPCs have been sleeping (ctrl-q)'>s</td>" +
            "<td class='scoreHeader' title='virtual gamepad used during game'>vgp</td>" + 
            "</tr>";
         // Check for any mouse usage by the players as you write out the rows.
         var someMouseFunnyBz = false;
         for (let score of cP.Client.scoreSummary) {
            if (score['mouse'] == 'x') someMouseFunnyBz = true;
            summaryString += "<tr align='right'>" + 
            "<td class='score'>" + score['name']     + "</td>" + 
            "<td class='score'>" + score['winner']   + "</td>" + 
            "<td class='score'>" + score['score']    + "</td>" + 
            "<td class='score'>" + score['mouse']    + "</td>" +
            "<td class='score'>" + score['npcSleep'] + "</td>" +
            "<td class='score'>" + score['virtualGamePad'] + "</td>" +
            "</tr>";
         }
      }
      
      // Now report the sorted score summary (pass in function to give descending numeric sort)
      summaryString += "</table>"
      hC.displayMessage( summaryString);
      
      // If any of the players or the host (without a puck player) used the mouse, mark everyone 
      // as suspect before doing the submission to the leaderboard. This appropriately blocks the case where the host
      // turns off his player and uses his mouse to delete the drones and lets one network player win.
      // That's clever, but that's not allowed.
      // (Notice the word "of" here. This type of for-of loop works nicely on arrays, and presents the item, not simply the index.)
      for (let score of cP.Client.scoreSummary) {
         // For Jello Madness or Ghost-ball pool, don't check for mouse usage. Mouse is always used.
         if ((c.demoIndex == 6) || (c.demoIndex == 3)) {
            score['mouse'] = '';
         } else {
            if (someMouseFunnyBz || clients['local'].mouseUsage) score['mouse'] = 'x';
         }
      }
   }
   
   
   /*
   Tried using the B2D contact listener to detect tangle. But this
   approach fails to deal with a tangled state where the balls are not quite
   touching... So the approach above is used.
   
   function checkForJelloTangle2() {
      if (c.contactCounter > 0) {
         c.jello.tangleTimer_s += c.deltaT_s;
      }
      ctx.font = "30px Arial";
      ctx.fillStyle = 'yellow';
      ctx.fillText(c.jello.tangleTimer_s.toFixed(2),10,50);
   }
   */
   
   // For loading and running a capture from a web page link.
   function demoStart_fromCapture( index, pars) {
      var fileName = setDefault( pars.fileName, 'null');
      console.log('fetching ' + fileName + ' from server');
      $.getScript( fileName, function() {
         // Note: demo_capture is a page level global and is assigned a value, the capture object, in the first line of the loading file.
         // Put the capture into the capture input box on the page.
         dC.json.value = JSON.stringify( demo_capture, null, 3);
         window.setTimeout( function() { scrollCaptureArea();}, 500);
         demoStart( index);
      }).fail( function() {
         // Try again...
         messages['help'].newMessage("please wait...", 5.0);
         console.log('attempting second fetch ' + fileName + ' from server');
         $.getScript( fileName, function() {
            dC.json.value = JSON.stringify( demo_capture, null, 3);
            window.setTimeout( function() { scrollCaptureArea();}, 500);
            demoStart( index);

         }).fail( function() {
            console.log('capture file not found on server');
            messages['help'].newMessage("Unable to get this capture file from the server: " + fileName + 
                                        "\\  please try again...", 10.0);
         });
      });
   }
      
   function hL( id) {
      /*      
      hL is short for highlighting...
      This inserts a style string in the links of the "Plus" row below the 
      number button cluster. So, when a link in the plus row is clicked (or a 
      number clicked), the demo is loaded and started, and each link in the 
      row is rebuilt (using calls to hL) and highlighted according to whether 
      it matches the first part of the demo version (e.g. 3.d). See calls to 
      hL in demoStart. See related code (that modifies the plus row) in 
      saveState, clearState, and near the top of demoStart.
      */
      //console.log(id + ',' + c.demoVersion.slice(0,3) + ',' + c.demoVersion.length);
      var idString = " id='" + id + "' ";
      
      // the link id that matches demoVersion (from capture in the text area), e.g. 2.c
      if (id == c.demoVersion.slice(0,3)) {
         // special captures have a black border, e.g. 2.c.334
         if (c.demoVersion.length > 3) {
            var styleString = "style='color:white; background-color:gray; border-style:solid; border-color:black; border-width:4px 0px 4px 0px'";
         // link ids that match a regular selected capture get a gray background (without a black border)
         } else {
            var styleString = "style='color:white; background-color:gray; padding:2px 0px'";
         }
      // link ids that don't match get a normal background color (off white)  
      } else {
         var styleString = "style='padding:2px 0px'";
      }
      
      return idString + styleString;
   }
   
   function clearTable( wallMode = "all") {
      hostMSelect.resetAll();
      
      // Delete pucks, references to them, and their representation in the b2d world.
      cP.Puck.deleteAll();  // this also resets the jello array...
      
      // Clean out the old springs.
      cP.Spring.deleteAll();
      // Clean out the old joints.
      cP.Joint.deleteAll();
      
      // Clean out the non-player clients
      cP.Client.deleteNPCs();
      
      // Clean out the old pins and their representation in the b2d world.
      cP.Pin.deleteAll();
      
      if (wallMode == "all") {
         // Clean out the old walls and their representation in the b2d world.
         cP.Wall.deleteAll();
         
      } else if (wallMode == "all-but-fence") {
         cP.Wall.deleteAllButFence();
         
      } else if (wallMode == "all-but-walls") {
         // Don't delete any walls.
      }
      
   }
   
   function demoStart( index, pars = {}) {
      var v_init_2d_mps, buttonColor, buttonTextColor;
      var p1, p2, p3, p4;
      
      var scrollCA = setDefault( pars.scrollCA, true);
      var scrollHelp = setDefault( pars.scrollHelp, true);
      var restartLoop = setDefault( pars.restartLoop, true);
      
      aT.collisionCount = 0;
      aT.collisionInThisStep = false;
      
      // by default no blending
      ctx.globalCompositeOperation = 'source-over';
            
      // Scroll the capture, so you can see the name of the capture if it's there.
      // However, nice to be able to edit the capture and run it without losing the spot where
      // you're working. In that case, set scrollCA to be false.
      if (scrollCA) scrollCaptureArea();
      
      // Set this module-level value to support the JSON capture.
      c.demoIndex = index;
      var networkPuckTemplate = null;
      
      dC.extraDemos.innerHTML = '';
      
      // Scaling factor between the Box2d world and the screen (pixels per meter)
      c.px_per_m = 100;  // a module-level value
      
      c.fullScreenDemo = false;
      c.lockedAndLoaded = false;
      
      canvas.width = 600, canvas.height = 600;
      c.canvasColor = 'black';
      ctx.scale(1, 1);
      clearCanvas();
      
      // The canvas background color must match the border color to avoid border edge problems in Chrome. (border is usually '#008080')
      canvas.style.borderColor = c.borderAndBackGroundColor;
      if (c.fullScreenState) {
         // There's no border in full-screen mode.
         canvas.style.backgroundColor = 'black';
      } else {
         canvas.style.backgroundColor = c.borderAndBackGroundColor;
      }
      
      cP.Wall.topFenceLegName = null; // For use in piCalcEngine
      
      if (index != 8) {  // see layout adjustments for demo 8 in (index == 8) block below
         adjustSizeOfChatDiv('normal'); // on the host (note: see demo 8 where a smaller chat div is set).
         hC.resizeClients('normal'); // adjust the chat div on all the clients
         // Set this module-level value to help new connecting clients adjust their layout.
         c.chatLayoutState = 'normal';
      }
                
      // Change the color of the demo button that was clicked.
      for (var j = 1; j <= 9; j++) {
         if (j == index) {
            buttonColor = "yellow";
            buttonTextColor = "black";
         } else {
            // Darkgray (with white text) for the game buttons
            if ((j == 3) || (j == 6) || (j == 7) || (j == 8)) {
               buttonColor = "darkgray";
               buttonTextColor = "white";
            } else {
               buttonColor = "lightgray";
               buttonTextColor = "black";
            }
         }
         document.getElementById('b'+j).style.backgroundColor = buttonColor;
         document.getElementById('b'+j).style.color = buttonTextColor;
         
         dC.indexInPlusRow.innerHTML = index + ":";
         //dC.indexInPlusRow.style.backgroundColor = "yellow";
         //dC.indexInPlusRow.style.color = "black";
      }
      
      clearTable();
      gB.resetTableHistory();
      cP.Client.startingPandV = [];
      
      // Reset input devices (key states sometimes get stuck down).
      cP.Client.applyToAll( client => {
         // Force a mouseUp event.
         mouseUp_handler( client.name);
         
         client.touchScreenUsage = false;
         
         // Reset all keys to be up.
         // Don't reset the key that corresponds to the current demo. That avoids repetition if holding down a demo key (see comments in keydown listener).
         let keyBeingPressed = 'key_' + index;
         for (var key in keyMap) {
            if (keyMap[key] != keyBeingPressed) {
               client[gW.keyMap[key]] = 'U';
            }
         }
      });
      
      // De-select anything still selected.
      clients['local'].selectedBody = null;
      hostMSelect.resetAll();
            
      resetFenceColor( "white");
      if (dC.pause.checked) {
         dC.pause.checked = false;
      }
      setPauseState(); // this will start the game loop only if it IS NOT running.
      if (restartLoop) restartAnimationLoop( 200); // this will restart the loop only if it IS running.
      
      // Turn gravity off by default.
      if (c.g_ON) {
         c.g_ON = false;
         dC.gravity.checked = false;
      }
      
      resetRestitutionAndFrictionParameters();
      setGravityRelatedParameters({});
      
      cP.Puck.bulletAgeLimit_ms = 1000;
      
      // reset the pi stuff back to defaults
      c.piCalcs = {'clacks':false, 'usePiEngine':false};
      
      // These message resets shut down any lingering messages from prior demos.
      messages['help'].resetMessage();
      messages['help'].loc_px = {'x':15,'y':30}; // The help location for all the non-game demos.
      messages['help2'].loc_px = {'x':15,'y':200}; // report for a selected spring.
      messages['win'].resetMessage();
      messages['lowHelp'].resetMessage();
      messages['gameTitle'].resetMessage();
      if (messages['videoTitle']) messages['videoTitle'].resetMessage();
            
      // By default, use "a" for the demoVersion. 
      // (Loading a capture will overwrite this default value, as it should.)
      // When a capture is taken, its name will be based on (added to) this demo version name.
      c.demoVersion = index + '.a';
      
      // Convert (parse) the json capture into a local object.
      if (dC.json.value != '') {
         try {
            var state_capture = JSON.parse( dC.json.value);
         } catch (err) {
            var state_capture = null;
            window.alert("There's a formatting error in the state capture. Try clicking the 'Clear' button.");
         }
      } else {
         var state_capture = null;
      }
      
      // pool game locks and settings
      if ( (state_capture) && (index == 3) && (state_capture.demoVersion.slice(0,3) == "3.d") ) {
         // Initiate new clients that don't have any pool-game locks set. Restarting the
         // pool game will not reset values for a continuing player.
         cP.Client.applyToAll( client => {
            if (!((client.ctrlShiftLock) || (client.poolShotLocked))) {
               client.ctrlShiftLock = true;
               client.poolShotLocked = true;
               client.poolShotLockedSpeed_mps = 20;
               client.fineMovesState = 'off';
            }
         });
      } else {
         // Turn off the pool game locks when starting all other demos.
         cP.Client.applyToAll( client => {
            client.ctrlShiftLock = false;
            client.poolShotLocked = false;
            client.poolShotLockedSpeed_mps = 0;
         });
      }
      
      if (index == 0) {
         clearState();
         if (document.fullscreenElement) hC.changeFullScreenMode( canvas, 'off');
         scrollDemoHelp('scroll-to-very-top');
                  
         // Normally, the "0" demo is kept blank for observing the framerate.
         
         /*
         // The following is an animation that was used in the beginning of the Puck Popper video.
         //canvas.width = 1250, canvas.height = 950;
         //canvas.width = 1920, canvas.height = 1080;
         canvas.width = 1850, canvas.height = 1060;
         cP.Wall.makeFence({'tOn':false,'rOn':false}, canvas); // Turn top and right walls off.
         
         messages['videoTitle'].font = "35px Arial";
         messages['videoTitle'].loc_px = {'x':300,'y':400};
         messages['videoTitle'].popAtEnd = false;
         var theSeries = {
            1:{'tL_s':1.5, 'message':"an introduction..."},
            2:{'tL_s':1.5, 'message':"maybe less...",            'loc_px':{'x':300,'y':400} },
            3:{'tL_s':1.5, 'message':"maybe more...",            'loc_px':{'x':300,'y':450} },
            4:{'tL_s':1.5, 'message':"than you should know...",  'loc_px':{'x':300,'y':400} },
            6:{'tL_s':1.5, 'message':"about...",                 'loc_px':{'x':300,'y':450},                      'popAtEnd':true},
            7:{'tL_s':1.3, 'message':"Puck",                     'loc_px':{'x':250,'y':350}, 'font':"90px Arial", 'popAtEnd':true},
            8:{'tL_s':1.5, 'message':"Popper",                   'loc_px':{'x':300,'y':450},                      'popAtEnd':false},
            
            9:{'tL_s':1.0, 'message':"...",                                 'loc_px':{'x':300,'y':450}, 'font':"35px Arial"},
            10:{'tL_s':1.5, 'message':"but first...",                       'loc_px':{'x':300,'y':450}, 'font':"35px Arial"},
            11:{'tL_s':3.0, 'message':"a game of the #8c version...", 'loc_px':{'x':300,'y':450} },
         };
         messages['videoTitle'].newMessageSeries( theSeries);
         
         var nBalls = 36; //100 36 180
         var angle_step_deg = 360.0 / nBalls;
         var v_2d_mps = new cP.Vec2D(0, 2.0);
         // 12.5/2, 9.5/2
         for (var i = 1; i <= nBalls; i++) {
               new cP.Puck(new cP.Vec2D(3.0, 3.0), v_2d_mps, {'radius_m':0.1, 'groupIndex':-1, 'color':'white', 'friction':0.0});
               // Rotate for the next ball.
               v_2d_mps.rotated_by( angle_step_deg);
         }
         */
         
      } else if (index == 1) {
         
         if ((state_capture) && (state_capture.demoIndex == 1)) {
            restoreFromState( state_capture);
            
            if ( ['1.c','1.d','1.e','1.f'].includes( demoVersionBase( c.demoVersion)) ) {
               var enginePars = Object.assign({}, cP.PiEngine.state, c.piCalcs);
               piCalcEngine = new cP.PiEngine( aT.puckMap['puck1'], aT.puckMap['puck2'], sounds['clack2'], enginePars);
               cP.PiEngine.state = {}; // done with pi engine state data from the restore of the capture...
               
               if (c.piCalcs.enabled) {
                  var massRatio = Math.round( aT.puckMap['puck2'].mass_kg / aT.puckMap['puck1'].mass_kg);
                  var massRatio_string = massRatio.toLocaleString(); // commas in the string
                  messages['lowHelp'].newMessage("Mass ratio = " + massRatio_string, 3.0);
                  
                  if (c.piCalcs.usePiEngine) {
                     var initialCount = piCalcEngine.collisionCount;
                  } else {
                     var initialCount = aT.collisionCount;
                  }
                  // This initial message is updated in the engines (box2d and piCalcs).
                  messages['help'].newMessage("count = " + initialCount, 30.0);
               }
            }
            
         } else {
            cP.Wall.makeFence({}, canvas);
            
            var v_init_2d_mps = new cP.Vec2D(0.0, -2.0);
            new cP.Puck( new cP.Vec2D(2.0, 3.99),       v_init_2d_mps, {'radius_m':0.10, 'color':'GoldenRod', 'colorSource':true, 'bullet':true});
            new cP.Puck( new cP.Vec2D(2.0, 3.00),       v_init_2d_mps, {'radius_m':0.80                                         , 'bullet':true});
            
            var v_init_2d_mps = new cP.Vec2D(0.0,  2.0);
            new cP.Puck( new cP.Vec2D(5.00, 1.60+1.5*2), v_init_2d_mps, {'radius_m':0.35                                         , 'bullet':true});
            new cP.Puck( new cP.Vec2D(5.00, 1.60+1.5),   v_init_2d_mps, {'radius_m':0.35, 'color':'GoldenRod', 'colorSource':true, 'bullet':true});
            new cP.Puck( new cP.Vec2D(5.00, 1.60),       v_init_2d_mps, {'radius_m':0.35                                         , 'bullet':true});
            
            new cP.Puck( new cP.Vec2D(0.50, 5.60), new cP.Vec2D(0.40, 0.00), {'radius_m':0.15, 'bullet':true});
         }
         
         dC.extraDemos.innerHTML = 
            "<a title='big and little'     " + hL('1.a') + " onclick=\"gW.clearState(); gW.demoStart(1)\">&nbsp;a,</a>" +
            "<a title='a gentle landing' "   + hL('1.b') + " onclick=\"gW.demoStart_fromCapture(1, {'fileName':'demo1b.js'})\">&nbsp;b,</a>" +
            "<a title='calculating the first two digits of pi with collisions' " + hL('1.c') + " onclick=\"gW.demoStart_fromCapture(1, {'fileName':'demo1c.js'})\">&nbsp;c,</a>" +
            "<a title='three digits of pi' " + hL('1.d') + " onclick=\"gW.demoStart_fromCapture(1, {'fileName':'demo1d.js'})\">&nbsp;d,</a>" +
            "<a title='five digits of pi' " + hL('1.e') + " onclick=\"gW.demoStart_fromCapture(1, {'fileName':'demo1e.js'})\">&nbsp;e&nbsp;</a>";
         
         if (scrollHelp) {
            if ( ['1.c','1.d','1.e'].includes( demoVersionBase( c.demoVersion)) ) {               
               scrollDemoHelp('#d1_pi');
            } else {
               scrollDemoHelp('#d1');
            }
         }
         
      } else if (index == 2) {
         
         if (scrollHelp) scrollDemoHelp('#d2');
         
         cP.Puck.restitution_gOn =  0.7;
         cP.Puck.friction_gOn =  0.6;
         
         cP.Puck.restitution_gOff = 1.0;
         cP.Puck.friction_gOff = 0.0;
         
         if ((state_capture) && (state_capture.demoIndex == 2)) {
            restoreFromState( state_capture);
            
         } else {
            cP.Wall.makeFence({}, canvas);
            new cP.Puck( new cP.Vec2D(4.5, 4.5), new cP.Vec2D(-4.0, 4.0), {'radius_m':0.20, 'friction':0.0, 'angleLine':false, 'color':'yellow', 'colorSource':true,
                                                                  'createTail':true, 
                                                                  'tailPars':{
                                                                     'propSpeed_ppf_px':2, 'length_limit':35,
                                                                     'color':'lightgray',
                                                                     'rainbow':false, 'rbSaturation': 75, 'rbLightness': 40,
                                                                     'machSwitch':false, 'machValue':0} });
                                                                  
            new cP.Puck( new cP.Vec2D(3.0, 3.0), new cP.Vec2D( 0.0, 0.0), {'radius_m':0.60, 'friction':0.0, 'angleLine':false, 'color':'GoldenRod', 'colorSource':true });
                                                                  
            new cP.Puck( new cP.Vec2D(1.5, 1.5), new cP.Vec2D( 0.0, 0.0), {'radius_m':0.20, 'friction':0.0, 'angleLine':false, 'color':'blue', 'colorSource':true,
                                                                  'createTail':true, 
                                                                  'tailPars':{
                                                                     'propSpeed_ppf_px':2, 'length_limit':35,
                                                                     'color':'lightgray',
                                                                     'rainbow':false, 'rbSaturation': 75, 'rbLightness': 40,
                                                                     'machSwitch':false, 'machValue':0} });
         }
         
         dC.extraDemos.innerHTML = 
            "<a title='sound field'                          " + hL('2.a') + " onclick=\"gW.clearState(); gW.demoStart(2)\">&nbsp;a,</a>" +
            "<a title='pretty'                               " + hL('2.b') + " onclick=\"gW.demoStart_fromCapture(2, {'fileName':'demo2b.js'})\">&nbsp;b,</a>" +
            "<a title='Mach speeds of 1.0, 1.4, and 2.0'     " + hL('2.c') + " onclick=\"gW.demoStart_fromCapture(2, {'fileName':'demo2c.js'})\">&nbsp;c,</a>" +
            "<a title='tag'                                  " + hL('2.d') + " onclick=\"gW.demoStart_fromCapture(2, {'fileName':'demo2d.js'})\">&nbsp;d,</a>" +
            "<a title='rainbow'                              " + hL('2.e') + " onclick=\"gW.demoStart_fromCapture(2, {'fileName':'demo2e.js'})\">&nbsp;e&nbsp;</a>";
            
         if (c.demoVersion.slice(0,3) == "2.e") {
            var messageString =                                 'Play with the rainbow tail:';
            if ( ! document.fullscreenElement) messageString += '\\    click the full-canvas button, then...';
            messageString +=                                    '\\    click and drag the black ball.';
            messages['help'].newMessage( messageString, 3.0);
         }
         
      } else if (index == 3) {
         
         cP.Puck.restitution_gOn =  0.7;
         cP.Puck.friction_gOn =  0.6;
         
         cP.Puck.restitution_gOff = 1.0;
         cP.Puck.friction_gOff = 0.0;
         
         v_init_2d_mps = new cP.Vec2D(0.0, 2.0); 
         
         if ((state_capture) && (state_capture.demoIndex == 3)) {
            restoreFromState( state_capture);
            
            if (c.demoVersion.slice(0,3) == "3.d") {               
               setNickNameWithoutConnecting();
               cP.Client.resetScores();
               
               c.canvasColor = '#2b473b'; // #36594a
               
               messages['gameTitle'].setFont('50px Arial');
               messages['gameTitle'].loc_px = {'x':75,'y':220};
               messages['gameTitle'].popAtEnd = false;
               messages['gameTitle'].newMessage("ghost-ball \\  pool", 2.0);
               if (c.demoVersion.includes('9ball')) {
                  messages['gameTitle'].addToIt("\\                9-ball");
               } else if (c.demoVersion.includes('8ball')) {
                  messages['gameTitle'].addToIt("\\ \\                          8-ball");
               } else if (c.demoVersion.includes('rotation')) {
                  messages['gameTitle'].addToIt("\\ \\                          simple rotation");
               }
               
               messages['help'].loc_px = {'x':75,'y':90};
               messages['help'].newMessageSeries({
                  1:{'tL_s':5.0, 'message':'release the ghost:' + 
                                         '\\    use the mouse to drag the ghost ball out of the cue ball'},
                  2:{'tL_s':5.0, 'message':"aim your shot:" + 
                                         "\\    touch the ghost ball against an object ball or cushion for alignment aids"},
                  4:{'tL_s':5.0, 'message':'shoot the cue ball:' + 
                                         '\\    release the mouse button ' + 
                                         '\\    (release over the cue ball to cancel the shot)'},
                  5:{'tL_s':7.0, 'message':'adjust the cue ball [base,yellow]speed[base]:' +
                                         '\\    tap the "z" key while dragging the ghost ball' +
                                         "\\    (speed [base,yellow]value[base] is based on ghost-cue separation)" +
                                         "\\    alternately use the mouse wheel"},
                  6:{'tL_s':5.0, 'message':'full-screen view:' +
                                         '\\    press the "v" key to fill the screen with the pool table' +
                                         '\\    press the "esc" key to return to the normal view'},
                  7:{'tL_s':5.0, 'message':"restart the game (and this help):" + 
                                         "\\    press #3 key " + 
                                         "\\    (above the w, not on keypad)"},
                  8:{'tL_s':2.0, 'message':"play some pool..."}
               });
            }
            
         } else {
            cP.Wall.makeFence({}, canvas);
            
            var grid_order = 7;
            var grid_spacing_m = 0.45;
            var startPosition_2d_m = new cP.Vec2D(0.0, 0.0);
            
            for (var i = 1; i <= grid_order; i++) {
               for (var j = 1; j <= grid_order; j++) {
                  var delta_2d_m = new cP.Vec2D( i * grid_spacing_m, j * grid_spacing_m);
                  var position_2d_m = startPosition_2d_m.add( delta_2d_m);
                  new cP.Puck(position_2d_m, v_init_2d_mps, {'radius_m':0.10, 'groupIndex':0});
               }
            }
            
            v_init_2d_mps = new cP.Vec2D(0.2, 0.0);
            new cP.Puck( new cP.Vec2D(5.5, 3.5), v_init_2d_mps, {'radius_m':0.10, 'color':'GoldenRod', 'colorSource':true, 'groupIndex':0} );
            
            /*
            // Expanding ring of non-colliding balls.
            var nBalls = 36; //100 36 180
            var angle_step_deg = 360.0 / nBalls;
            var v_2d_mps = new cP.Vec2D(0, 2.0);
            for (var i = 1; i <= nBalls; i++) {
                  new cP.Puck(new cP.Vec2D(3, 3), v_2d_mps, {'radius_m':0.1, 'groupIndex':-1, 'color':'white'});
                  // Rotate for the next ball.
                  v_2d_mps.rotated_by( angle_step_deg);
            }
            window.setTimeout( function() {
               saveState();
            }, 1);
            */
         }
         
         dC.extraDemos.innerHTML = 
            "<a title='order and disorder'      " + hL('3.a') + " onclick=\"gW.clearState(); gW.demoStart(3)\">&nbsp;a,</a>" +
            "<a title='no puck-puck collisions' " + hL('3.b') + " onclick=\"gW.demoStart_fromCapture(3, {'fileName':'demo3b.js'})\">&nbsp;b,</a>" +
            "<a title='no puck-puck collisions' " + hL('3.c') + " onclick=\"gW.demoStart_fromCapture(3, {'fileName':'demo3c.js'})\">&nbsp;c,</a>" +
            "<a title='pool shots' "              + hL('3.d') + " onclick=\"gW.demoStart_fromCapture(3, {'fileName':'demo3d.js'})\">&nbsp;d&nbsp;</a>";
            
         if (scrollHelp) {
            if ( ['3.d'].includes( demoVersionBase( c.demoVersion)) ) {               
               scrollDemoHelp('#d3d');
            } else {
               scrollDemoHelp('#d3');
            }
         }
         
      } else if (index == 4) {
         
         if (scrollHelp) scrollDemoHelp('#d4');
         
         cP.Puck.restitution_gOn =  0.7;
         cP.Puck.friction_gOn =  0.6;
         
         cP.Puck.restitution_gOff = 1.0;
         cP.Puck.friction_gOff = 0.0;
                 
         if ((state_capture) && (state_capture.demoIndex == 4)) {
            restoreFromState( state_capture);
            
         } else {
            cP.Wall.makeFence({}, canvas);
            
            new cP.Puck( new cP.Vec2D(3.00, 3.00), new cP.Vec2D( 0.0, 0.0), 
               {'radius_m':0.40, 'color':'GoldenRod', 'colorSource':true , 'shape':'rect', 'angularSpeed_rps':25.0});
            
            new cP.Puck( new cP.Vec2D(0.25, 3.00), new cP.Vec2D( 2.0, 0.0), 
               {'radius_m':0.15, 'shape':'rect', 'aspectR':4.0, 'angularSpeed_rps':0, 'angle_r': Math.PI/2});
            new cP.Puck( new cP.Vec2D(5.75, 3.00), new cP.Vec2D(-2.0, 0.0), 
               {'radius_m':0.15, 'shape':'rect', 'aspectR':4.0, 'angularSpeed_rps':0, 'angle_r': Math.PI/2});
               
            // Include two pins and a spring as a source for replicating. 
            new cP.Spring( new cP.Pin( new cP.Vec2D( 0.1, 0.2),{}), new cP.Pin( new cP.Vec2D( 0.1, 1.2),{}), 
                 {'length_m':1.5, 'strength_Npm':10.0, 'unstretched_width_m':0.1, 'color':'yellow', 'damper_Ns2pm2':1.0});
         }
         
         dC.extraDemos.innerHTML = 
            "<a title='rectangular symmetry'                " + hL('4.a') + " onclick=\"gW.clearState(); gW.demoStart(4)\">&nbsp;a,</a>" +
            "<a title='conservation of angular momentum...' " + hL('4.b') + " onclick=\"gW.demoStart_fromCapture(4, {'fileName':'demo4b.js'})\">&nbsp;b,</a>" +
            "<a title='no surface friction or y momentum' "   + hL('4.c') + " onclick=\"gW.demoStart_fromCapture(4, {'fileName':'demo4c.js'})\">&nbsp;c,</a>" +
            "<a title='little moves big' "                    + hL('4.d') + " onclick=\"gW.demoStart_fromCapture(4, {'fileName':'demo4d.js'})\">&nbsp;d&nbsp;</a>";
         
      } else if (index == 5) {
         
         cP.Puck.restitution_gOn =  0.7;
         cP.Puck.friction_gOn =  0.6;
         
         cP.Puck.restitution_gOff = 1.0;
         cP.Puck.friction_gOff = 0.0;

         v_init_2d_mps = new cP.Vec2D(0.0,0.0);         
         
         if ((state_capture) && (state_capture.demoIndex == 5)) {
            restoreFromState( state_capture);
            
            if (c.demoVersion.includes('basketball')) {
               bpH.setUpPreGameHelp();
            }        
            
         } else {
            cP.Wall.makeFence({}, canvas);
            
            // Spring triangle.
            var tri_vel_mps = new cP.Vec2D( 5.0, 0.0);
            var d5_puckPars_triangle = {'radius_m':0.20, 'restitution':0.0, 'friction':1.0}
            new cP.Puck( new cP.Vec2D(1.00, 0.80 + Math.sin(60.0*Math.PI/180)), tri_vel_mps, Object.assign({}, d5_puckPars_triangle, {'name':'puck1'}));
            
            tri_vel_mps.rotated_by(-240.0);
            new cP.Puck( new cP.Vec2D(0.50, 0.80                             ), tri_vel_mps, Object.assign({}, d5_puckPars_triangle, {'name':'puck2'}));
            
            tri_vel_mps.rotated_by(-240.0);
            new cP.Puck( new cP.Vec2D(1.50, 0.80                             ), tri_vel_mps, Object.assign({}, d5_puckPars_triangle, {'name':'puck3'}));
            
            var springColor1 = 'blue';
            new cP.Spring(aT.puckMap['puck1'], aT.puckMap['puck2'], 
                                        {'length_m':1.1, 'strength_Npm':60.0, 'unstretched_width_m':0.1, 'color':springColor1});
            new cP.Spring(aT.puckMap['puck1'], aT.puckMap['puck3'], 
                                        {'length_m':1.1, 'strength_Npm':60.0, 'unstretched_width_m':0.1, 'color':springColor1});
            new cP.Spring(aT.puckMap['puck2'], aT.puckMap['puck3'], 
                                        {'length_m':1.1, 'strength_Npm':60.0, 'unstretched_width_m':0.1, 'color':springColor1});
            
            var springColor2 = 'yellow';
            
            // Single puck with two springs and pins.
            new cP.Puck( new cP.Vec2D(4.0, 5.0), new cP.Vec2D(0.0, 0.0), {'radius_m':0.55, 'name':'puck4', 'restitution':0.0, 'angDamp':0.0, 'linDamp':2.0, 'friction':1.0});
            var d5_springPars_onePuck = {'strength_Npm':20.0, 'unstretched_width_m':0.1, 'color':springColor2, 'damper_Ns2pm2':0.0, 'drag_c':0.0};
            new cP.Spring(aT.puckMap['puck4'], new cP.Pin( new cP.Vec2D( 3.0, 5.0),{borderColor:'yellow'}), 
                  Object.assign({}, d5_springPars_onePuck, {'spo1_ap_l_2d_m':new cP.Vec2D( 0.54, 0.01)}) );
            new cP.Spring(aT.puckMap['puck4'], new cP.Pin( new cP.Vec2D( 5.0, 5.0),{borderColor:'yellow'}), 
                  Object.assign({}, d5_springPars_onePuck, {'spo1_ap_l_2d_m':new cP.Vec2D(-0.54, 0.00)}) );
                                        
            // Two pucks (one bigger than the other) on spring orbiting each other (upper left corner)
            new cP.Puck( new cP.Vec2D(0.75, 5.00), new cP.Vec2D(0.0, -5.00                          * 1.2), {'radius_m':0.15, 'name':'puck5'});
            // Scale the y velocity by the square of the radius ratio. This gives a net momentum of zero (so it stays in one place as it spins).
            new cP.Puck( new cP.Vec2D(1.25, 5.00), new cP.Vec2D(0.0, +5.00 * Math.pow(0.15/0.25, 2) * 1.2), {'radius_m':0.25, 'name':'puck6'});
            new cP.Spring(aT.puckMap['puck5'], aT.puckMap['puck6'], 
                                        {'length_m':0.5, 'strength_Npm':5.0, 'unstretched_width_m':0.05, 'color':springColor2});
                                        
            // Same thing (lower right corner)
            new cP.Puck( new cP.Vec2D(4.70, 0.55), new cP.Vec2D(+4.90, 0.0), {'radius_m':0.20, 'name':'puck7'});
            new cP.Puck( new cP.Vec2D(4.70, 1.55), new cP.Vec2D(-4.90, 0.0), {'radius_m':0.20, 'name':'puck8'});
            new cP.Spring(aT.puckMap['puck7'], aT.puckMap['puck8'], 
                                        {'length_m':0.5, 'strength_Npm':5.0, 'unstretched_width_m':0.05, 'color':springColor2});
                                        
         }
         
         dC.extraDemos.innerHTML = 
            "<a title='stretchy things'          " + hL('5.a') + " onclick=\"gW.clearState(); gW.demoStart(5)\">&nbsp;a,</a>" +
            "<a title='Rube would like this...'  " + hL('5.b') + " onclick=\"gW.demoStart_fromCapture(5, {'fileName':'demo5b.js'})\">&nbsp;b,</a>" +
            "<a title='spring pendulum'          " + hL('5.c') + " onclick=\"gW.demoStart_fromCapture(5, {'fileName':'demo5c.js'})\">&nbsp;c,</a>" +
            "<a title='dandelion seeds'          " + hL('5.d') + " onclick=\"gW.demoStart_fromCapture(5, {'fileName':'demo5d.js'})\">&nbsp;d,</a>" +
            "<a title='chain-link loop using revolute joints' " + hL('5.e') + " onclick=\"gW.demoStart_fromCapture(5, {'fileName':'demo5e.js'})\">&nbsp;e,</a>" +
            "<a title='double-compound pendulum' " + hL('5.f') + " onclick=\"gW.demoStart_fromCapture(5, {'fileName':'demo5f.js'})\">&nbsp;f,</a>" +
            "<a title='wild west action'         " + hL('5.g') + " onclick=\"gW.demoStart_fromCapture(5, {'fileName':'demo5g.js'})\">&nbsp;g,</a>" +
            "<a title='Newton&#39;s cradle'      " + hL('5.h') + " onclick=\"gW.demoStart_fromCapture(5, {'fileName':'demo5h.js'})\">&nbsp;h&nbsp;</a>";

         
         // Scroll AFTER loading the capture (and setting c.demoVersion) so can scroll to the special help for the 5d demo.
         if (scrollHelp) {
            // Distance joints, Newton's cradle
            if ( ['5.h'].includes( demoVersionBase( c.demoVersion)) ) {                 
               scrollDemoHelp('#d5h');
            } else if ( c.demoVersion.includes('5.a.dandelion') || ['5.d'].includes( demoVersionBase( c.demoVersion)) ) {
               scrollDemoHelp('#d5d');
            // Distance joints, non-traditional springs
            } else if ( c.demoVersion.includes('5.a.soft') ) {
               scrollDemoHelp('#d5a_soft');
            // revolute joints
            } else if ( ['5.e','5.f','5.g'].includes( demoVersionBase( c.demoVersion)) ) {               
               scrollDemoHelp('#d5e');
            // 5a -- 5d
            } else {
               scrollDemoHelp('#d5');
            }
         }
      
      } else if (index == 6) {
         
         if (scrollHelp) scrollDemoHelp('#d6');
      
         setNickNameWithoutConnecting();
         messages['jelloTimer'].loc_px = {'x':15,'y': 40};
         
         c.g_ON = false;
         dC.gravity.checked = false;
         
         cP.Puck.restitution_gOn =  0.0;
         cP.Puck.friction_gOn =  0.6;
         
         cP.Puck.restitution_gOff = 0.0;
         cP.Puck.friction_gOff = 0.6;
         
         cP.Client.resetScores();
         
         if ((state_capture) && (state_capture.demoIndex == 6)) {
            restoreFromState( state_capture);
         
         } else if ( demo_6_fromFile) {
            restoreFromState( demo_6_fromFile);
            
         } else {
            cP.Wall.makeFence({}, canvas);
            makeJello({});
         }
         
         // For 6.a or 6.d or any capture based on them, run them like the Jello game.
         messages['help'].loc_px = {'x':15,'y': 75};
         messages['win'].loc_px =  {'x':15,'y':100};
         c.jello.reported = true;
         c.jello.tangleTimer_s = 0.0;
         if ((c.demoVersion.slice(0,3) == "6.a") || (c.demoVersion.slice(0,3) == "6.d")) {
            messages['help'].newMessage("Detangle the jello:\\    Try the f key. Try right-click mouse drags.", 3.0);
            
            messages['gameTitle'].newMessage("Jello Madness", 1.0);
            messages['gameTitle'].loc_px = {'x':15,'y':200};
            messages['gameTitle'].popAtEnd = false;
            
            c.jello.reported = false;
            c.jello.verifyingDeTangle = false;
         }
         
         setGravityRelatedParameters({});
      
         // An extra puck to play with.
         //puckParms.restitution = 0.0;
         //new cP.Puck( 3.8, 5.5, v_init_2d_mps, puck_radius_m * 2.8, puckParms);
         
         dC.extraDemos.innerHTML = 
            "<a title='Jello Madness'                            " + hL('6.a') + " onclick=\"gW.clearState(); gW.demoStart(6)\">&nbsp;a,</a>" +
            "<a title='the editor turned the jello into this...' " + hL('6.b') + " onclick=\"gW.demoStart_fromCapture(6, {'fileName':'demo6b.js'})\">&nbsp;b,</a>" +
            "<a title='the editor turned the jello into this...' " + hL('6.c') + " onclick=\"gW.demoStart_fromCapture(6, {'fileName':'demo6c.js'})\">&nbsp;c,</a>" +
            "<a title='a tough tangle...' " + hL('6.d') + " onclick=\"gW.demoStart_fromCapture(6, {'fileName':'demo6d.js'})\">&nbsp;d&nbsp;</a>";
         
      } else if (index == 7) {
         if (scrollHelp) scrollDemoHelp('#d7');
         
         messages['help'].loc_px = {'x':15,'y':75};
         
         messages['gameTitle'].loc_px = {'x':15,'y':200};
         messages['gameTitle'].popAtEnd = true;
         
         messages['score'].loc_px =   {'x':15,'y': 25};
         messages['ppTimer'].loc_px = {'x':15,'y': 45};
         messages['win'].loc_px =     {'x':15,'y':125};
         messages['lowHelp'].loc_px = {'x':15,'y':325};
         
         //hC.clearInputDefault( document.getElementById('inputField'));
         setNickNameWithoutConnecting();
         
         cP.Puck.restitution_gOn =  0.6; 
         cP.Puck.friction_gOn =  0.0;
         
         cP.Puck.restitution_gOff = 0.6; 
         cP.Puck.friction_gOff = 0.0;
         
         cP.Puck.bulletAgeLimit_ms = 1000;
         
         if ((state_capture) && (state_capture.demoIndex == 7)) {
            networkPuckTemplate = restoreFromState( state_capture);
            
         } else {
            cP.Wall.makeFence({}, canvas);
            
            // Normal pucks
            new cP.Puck( new cP.Vec2D(0.35, 0.35), new cP.Vec2D( 0.0, 4.0), {'radius_m':0.25}); //   , 'categoryBits':'0x0000', 'maskBits':'0x0000', 'color':'pink'
            new cP.Puck( new cP.Vec2D(5.65, 0.35), new cP.Vec2D( 0.0, 4.0), {'radius_m':0.25}); //   , 'categoryBits':'0x0000', 'maskBits':'0x0000', 'color':'pink'
            
            new cP.Puck( new cP.Vec2D(2.75, 0.35), new cP.Vec2D(+2.0, 0.0), {'radius_m':0.25});
            new cP.Puck( new cP.Vec2D(3.25, 0.35), new cP.Vec2D(-2.0, 0.0), {'radius_m':0.25});
            
            new cP.Puck( new cP.Vec2D(0.35, 5.65), new cP.Vec2D(+2.0, 0.0), {'radius_m':0.25});
            new cP.Puck( new cP.Vec2D(5.65, 5.65), new cP.Vec2D(-2.0, 0.0), {'radius_m':0.25});
            
            // Shelter
            //    Vertical part
            new cP.Wall( new cP.Vec2D( 3.0, 3.0), {'half_width_m':0.02, 'half_height_m':0.50});
            //    Horizontal part
            new cP.Wall( new cP.Vec2D( 3.0, 3.0), {'half_width_m':0.50, 'half_height_m':0.02});
            
            // Note the 'bullet_restitution':0.85 in what follows for the local and NPC client pucks. I have
            // also changed the 7b,c,d (captures) to include this parameter and value for all the driven pucks.
            
            // Puck for the local client (the host) to drive.
            var position_2d_m = new cP.Vec2D(3.0, 4.5);
            var velocity_2d_mps = new cP.Vec2D(0.0, 0.0);
            if (dC.player.checked) {
               // Make the requested puck for the host
               new cP.Puck( position_2d_m, velocity_2d_mps, cP.Puck.hostPars);
            } else {
               // Don't actually create a puck for the host. But collect parameters needed for creating the network pucks in a
               // way that reflects the birth parameters here.
               networkPuckTemplate = Object.assign({}, {'position_2d_m':position_2d_m, 'velocity_2d_mps':velocity_2d_mps}, cP.Puck.hostPars);
            }
            
            // A 4-pin track for NPC client navigation.
            var pinRadius = 3;
            var e1 = 1.5, e2 = 4.5;
            p1 = new cP.Pin( new cP.Vec2D( e1, e1), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':'pin4', 'name':'pin1', 'nextPinName':'pin2'});
            p2 = new cP.Pin( new cP.Vec2D( e2, e1), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':'pin1', 'name':'pin2', 'nextPinName':'pin3'});
            p3 = new cP.Pin( new cP.Vec2D( e2, e2), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':'pin2', 'name':'pin3', 'nextPinName':'pin4'});
            p4 = new cP.Pin( new cP.Vec2D( e1, e2), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':'pin3', 'name':'pin4', 'nextPinName':'pin1'});
            
            // Add local non-player clients (NPC, aka drones) and associated pucks to drive. Assign
            // a starting pin.
            new cP.Client({'name':'NPC1', 'color':'purple'});
            new cP.Puck( p1.position_2d_m, new cP.Vec2D(0.0, 0.0), {'radius_m':0.30, 'color':'darkblue', 'colorSource':false, 'clientName':'NPC1', 'hitLimit':20, 'pinName':'pin1', 'rayCast_init_deg':100,
                'bullet_restitution':0.85, 'linDamp':1.0} );
            //new cP.Client({'name':'NPC2', 'color':'purple'});
            //new cP.Puck( p3.position_2d_m, new cP.Vec2D(0.0, 0.0), {'radius_m':0.30, 'color':'darkblue', 'colorSource':false, 'clientName':'NPC2', 'linDamp':1.0, 
            //                                                        'hitLimit':20, 'pinName':'pin3', 'rayCast_init_deg':-90} );
            
            // A 2-pin navigation track for a single client.
            //var p5 = new cP.Pin( new cP.Vec2D( 5.0, 2.5), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':'pin6', 'name':'pin5', 'nextPinName':'pin6'});
            //var p6 = new cP.Pin( new cP.Vec2D( 5.0, 3.5), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':'pin5', 'name':'pin6', 'nextPinName':'pin5'});
            //new cP.Client({'name':'NPC3', 'color':'purple'});
            //new cP.Puck( new cP.Vec2D( 5.0, 2.5), new cP.Vec2D(0.0, 0.0), {'radius_m':0.30, 'color':'darkblue', 'colorSource':false, 'clientName':'NPC3', 'linDamp':1.0, 
            //                                                               'hitLimit':20, 'pinName':'pin5', 'rayCast_init_deg':0} );
            
            // Make a one single-pin track and corresponding NPC client.
            //pP.makeNPC_OnSinglePin(1, cP.Pin.nameIndex + 1, cP.Client.npcIndex + 1, new cP.Vec2D( 1.0, 1.0));
         }
            
         cP.Client.resetScores();
         
         if (state_capture && (state_capture.demoIndex == 7) && state_capture.startingPosAndVels) {
            cP.Client.startingPandV = state_capture.startingPosAndVels;
         } else {
            cP.Client.startingPandV = [ {'position_2d_m':new cP.Vec2D(2.6, 3.4), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                     {'position_2d_m':new cP.Vec2D(3.4, 3.4), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                     {'position_2d_m':new cP.Vec2D(3.4, 2.6), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                     {'position_2d_m':new cP.Vec2D(2.6, 2.6), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)} ];
         }
         pP.createPucksForNetworkClients( canvas, networkPuckTemplate, cP.Client.startingPandV);
         //cP.Client.setPuckCountAtGameStart(); 
         
         //if (cP.Client.getCountHumanPucks() > 0) {
         //   // Looks like someone is ready to play. Label this as a game.
         //   messages['gameTitle'].newMessage("Puck \\Popper", 1.0);
         //}
         pP.preGameSetUp( 7);
         
         dC.extraDemos.innerHTML = 
            "<a title='Puck Popper (1 drone on 4 pins)'  " + hL('7.a') + "  onclick=\"gW.clearState(); gW.demoStart(7)\">&nbsp;a,</a>" +
            "<a title='2 drones on 4 pins'               " + hL('7.b') + "  onclick=\"gW.demoStart_fromCapture(7, {'fileName':'demo7b.js'})\">&nbsp;b,</a>" +
            "<a title='4 drones on 5 pins'               " + hL('7.c') + "  onclick=\"gW.demoStart_fromCapture(7, {'fileName':'demo7c.js'})\">&nbsp;c,</a>" +
            "<a title='1 drone on 2 pins'                " + hL('7.d') + "  onclick=\"gW.demoStart_fromCapture(7, {'fileName':'demo7d.js'})\">&nbsp;d&nbsp;</a>";
         
      } else if (index == 8) {
         
         canvas.width = 1250, canvas.height = 950;
         adjustSizeOfChatDiv('small'); // on the host (note: chat div is set to normal as the default, see the beginning of demoStart)  
         hC.resizeClients('small');    // adjust chat div on the clients
         // Set this module-level value to help new connecting clients adjust their layout.
         c.chatLayoutState = 'small';
         
         // Must do this AFTER the chat-div adjustment.
         if (scrollHelp) scrollDemoHelp('#d8');
         
         messages['help'].loc_px = {'x':55,'y': 84};
         
         messages['gameTitle'].loc_px = {'x':55,'y':200};
         messages['gameTitle'].popAtEnd = true;
         
         messages['score'].loc_px   = {'x':55,'y': 35};
         messages['ppTimer'].loc_px = {'x':55,'y': 55};
         messages['win'].loc_px =     {'x':55,'y':120};
         messages['lowHelp'].loc_px = {'x':55,'y':325};
         
         hC.clearInputDefault( document.getElementById('inputField'));
         setNickNameWithoutConnecting();
         
         c.g_ON = false;
         dC.gravity.checked = false;
         
         cP.Puck.restitution_gOn =  0.7;  //0.7
         cP.Puck.friction_gOn =  0.6;
         
         // Keep the restitution 0.0 for gOff operation in all the 8 version demos. That way the drones fly
         // smoothly through the navigation channels in the terrain. setGravityRelatedParameters runs after
         // the drones are restored.
         cP.Puck.restitution_gOff = 0.0;  //1.0
         cP.Puck.friction_gOff = 0.6;
         
         //setGravityRelatedParameters({});
         
         cP.Puck.bulletAgeLimit_ms = 1500;
         
         if ((state_capture) && (state_capture.demoIndex == 8)) {
            networkPuckTemplate = restoreFromState( state_capture);
         
         } else if (demo_8_fromFile) {
            // Don't need to parse here because read in from a file.
            networkPuckTemplate = restoreFromState( demo_8_fromFile);
            
            // Some little walls in the middle.
            /*
            new cP.Wall( new cP.Vec2D( 2.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02, 'angularSpeed_rps':3.14});
            new cP.Wall( new cP.Vec2D( 3.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02});
            new cP.Wall( new cP.Vec2D( 4.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02, 'angularSpeed_rps':3.14/2});
            new cP.Wall( new cP.Vec2D( 5.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02});
            new cP.Wall( new cP.Vec2D( 6.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02, 'angularSpeed_rps':3.14});
            new cP.Wall( new cP.Vec2D( 7.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02});
            new cP.Wall( new cP.Vec2D( 8.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02, 'angularSpeed_rps':3.14/2});            
            */
            
            /*
            // Puck for the local client (the host) to drive.
            if (dC.player.checked) {
               new cP.Puck( new cP.Vec2D(3.0, 4.5), new cP.Vec2D(0.0, 0.0), {'radius_m':0.30, 'color':'black', 'colorSource':true, 'clientName':'local', 'linDamp':1.0, 'hitLimit':20} );
            }
            
            var pinRadius = 3;
            p1 = new cP.Pin( new cP.Vec2D( 1.0, 2.0), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':'pin103', 'name':'pin101', 'nextPinName':'pin102'});
            p2 = new cP.Pin( new cP.Vec2D( 1.0, 4.0), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':'pin101', 'name':'pin102', 'nextPinName':'pin103'});
            p3 = new cP.Pin( new cP.Vec2D( 1.0, 5.0), {'radius_px':pinRadius, 'NPC':true, 'previousPinName':'pin102', 'name':'pin103', 'nextPinName':'pin101'});
            */
            
            /*
            // Add some local non-player clients (NPCs)
            new cP.Client({'name':'NPC3', 'color':'purple'});
            new cP.Client({'name':'NPC4', 'color':'purple'});
            
            // Controllable pucks for these NPC clients; assign a starting pin.
            new cP.Puck( new cP.Vec2D( 1.0, 2.0), new cP.Vec2D(0.0, 0.0), {'radius_m':0.30, 'color':'darkblue', 'colorSource':false, 'clientName':'NPC3', 'linDamp':1.0, 'hitLimit':20, 'pinName':'pin102'} );
            new cP.Puck( new cP.Vec2D( 1.0, 2.0), new cP.Vec2D(0.0, 0.0), {'radius_m':0.30, 'color':'darkblue', 'colorSource':false, 'clientName':'NPC4', 'linDamp':1.0, 'hitLimit':20, 'pinName':'pin103'} );
            */
            
            // Make a set of drones and single-pin navigation tracks (use editor to add more pins if wanted). 
            //pP.makeNPC_OnSinglePin(3, cP.Pin.nameIndex + 1, cP.Client.npcIndex + 1, new cP.Vec2D( 1.0, 1.0));
            
         } else {
            makeJello({'pinned':true, 'gridsize':4});
          
            cP.Wall.makeFence({}, canvas);
            
            // Some little walls in the middle.
            new cP.Wall( new cP.Vec2D( 2.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02, 'angularSpeed_rps':3.14/2});
            new cP.Wall( new cP.Vec2D( 3.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02});
            new cP.Wall( new cP.Vec2D( 4.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02});
            new cP.Wall( new cP.Vec2D( 5.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02});
            new cP.Wall( new cP.Vec2D( 6.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02});
            new cP.Wall( new cP.Vec2D( 7.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02});
            new cP.Wall( new cP.Vec2D( 8.0, 0.5), {'half_width_m':0.4, 'half_height_m':0.02});
            
         }
         
         cP.Client.resetScores();
         
         if (state_capture && (state_capture.demoIndex == 8) && state_capture.startingPosAndVels) {
            cP.Client.startingPandV = state_capture.startingPosAndVels;
         } else {
            if        (c.demoVersion == '8.a') {
               cP.Client.startingPandV = [ {'position_2d_m':new cP.Vec2D( 9.34, 5.23), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(10.21, 7.61), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(10.21, 4.46), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D( 9.34, 6.84), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)} ];
            } else if (c.demoVersion == '8.b') {
               cP.Client.startingPandV = [ {'position_2d_m':new cP.Vec2D(1.3, 2.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(2.0, 2.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(3.0, 2.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(4.0, 2.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)} ];
            } else if (c.demoVersion == '8.c') {
               cP.Client.startingPandV = [ {'position_2d_m':new cP.Vec2D(2.77, 4.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(2.77, 3.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(2.77, 2.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(2.77, 1.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)} ];
            } else if (c.demoVersion == '8.d') {
               cP.Client.startingPandV = [ {'position_2d_m':new cP.Vec2D(4.95, 4.91), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(5.95, 4.91), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(6.95, 4.91), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(7.95, 4.91), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)} ];
            } else if (c.demoVersion == '8.e') {
               cP.Client.startingPandV = [ {'position_2d_m':new cP.Vec2D(2.0, 5.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(3.0, 5.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(4.0, 5.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(5.0, 5.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)} ];
            } else {
               cP.Client.startingPandV = [ {'position_2d_m':new cP.Vec2D(2.0, 6.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(3.0, 6.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(4.0, 6.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)},
                                        {'position_2d_m':new cP.Vec2D(5.0, 6.0), 'velocity_2d_mps':new cP.Vec2D(0.0, 0.0)} ];
            }
         }
         pP.createPucksForNetworkClients( canvas, networkPuckTemplate, cP.Client.startingPandV);
         //cP.Client.setPuckCountAtGameStart();
         
         //if (cP.Client.getCountHumanPucks() > 0) {
         //   // Looks like someone is ready to play. Label this as a game.
         //   messages['gameTitle'].newMessage("Puck \\Popper", 1.0);
         //}
         pP.preGameSetUp( 8);
         
         // Removing the old version of 8c (similar to 8b). 
         // File is still out there for running from a URL query string. Old one runs as 8f now.
         dC.extraDemos.innerHTML = 
           "<a title='Puck Popper (with jello)' " + hL('8.a') + " onclick=\"gW.clearState(); gW.demoStart(8)\" style='cursor: pointer'>&nbsp;a,</a>" +
           "<a title='high-noon maze' " + hL('8.b') + " onclick=\"gW.demoStart_fromCapture(8, {'fileName':'demo8b.js'})\">&nbsp;b,</a>" +
           "<a title='wide open spaces (no drag)' " + hL('8.c') + " onclick=\"gW.demoStart_fromCapture(8, {'fileName':'demo8c.js'})\">&nbsp;c,</a>" +
           "<a title='bullet energy (no drag, and elastic collisions)' " + hL('8.d') +
                                                   " onclick=\"gW.demoStart_fromCapture(8, {'fileName':'demo8d.js'})\">&nbsp;d,</a>" +
           "<a title='target-leading demo (no recoil, no drag, and elastic collisions)' " + hL('8.e') +
                                                   " onclick=\"gW.demoStart_fromCapture(8, {'fileName':'demo8e.js'})\">&nbsp;e&nbsp;</a>";
                  
      } else if (index == 9) {
         if (scrollHelp) scrollDemoHelp('#d9');
         
         canvas.style.borderColor = 'black';
         
         cP.Puck.restitution_gOn =  0.7;
         cP.Puck.friction_gOn =  0.6;
         
         cP.Puck.restitution_gOff = 1.0;
         cP.Puck.friction_gOff = 0.6;
         
         if ((state_capture) && (state_capture.demoIndex == 9)) {
            restoreFromState( state_capture);
            
         } else {            
            cP.Wall.makeFence({}, canvas);
            
            // To simulate additive color mixing.
            ctx.globalCompositeOperation = 'screen'; // 'source-over' 'screen'
            
            // pucks
            var puckStart_2d_m = new cP.Vec2D( 3.0, 3.0);
            var puckBasePars = {'radius_m':1.1, 'borderWidth_px':0, 'angleLine':false, 'colorSource':true, 'linDamp':1.0, 'angDamp':0.2, 'friction':1.0};
            // Green, Red, and Blue
            // Use Object.assign to make an independent pars object (a copy) that builds off the puckBasePars object. Note: it is important to
            // have the {} target in order to make a copy. If you use puckBasePars as the target, you'll just keep updating the reference to 
            // puckBasePars (not good).
            new cP.Puck( puckStart_2d_m, new cP.Vec2D(+0.08, -0.04),   Object.assign({}, puckBasePars, {'name':'puck1', 'color':'#00ff00'}));
            new cP.Puck( puckStart_2d_m, new cP.Vec2D(-0.08, -0.04),   Object.assign({}, puckBasePars, {'name':'puck2', 'color':'#ff0000'}));
            new cP.Puck( puckStart_2d_m, new cP.Vec2D( 0.00,  0.0894), Object.assign({}, puckBasePars, {'name':'puck3', 'color':'#0000ff'}));
            
            // Springs between the three pucks
            var springPars = {'length_m':1.0, 'strength_Npm':25.0, 'unstretched_width_m':0.125, 'visible':false, 'damper_Ns2pm2':0.5, 
                              'softConstraints':true, 'collideConnected':false, 'color':'white'};
            new cP.Spring( aT.puckMap['puck1'], aT.puckMap['puck2'], springPars);
            new cP.Spring( aT.puckMap['puck2'], aT.puckMap['puck3'], springPars);
            new cP.Spring( aT.puckMap['puck3'], aT.puckMap['puck1'], springPars);
            
            // Three weaker springs (on final-position pins) that bring the triangle back to a nice center position.
            var centeringSpringPars = {'length_m':0.0, 'strength_Npm':10.0, 'unstretched_width_m':0.05, 'visible':false, 'damper_Ns2pm2':0.5, 
                                       'softConstraints':true, 'collideConnected':false, 'color':'white'};
            p1 = new cP.Pin( new cP.Vec2D( 3.5, 2.711), {'visible':false, 'borderColor':'white', 'fillColor':'black'});
            p2 = new cP.Pin( new cP.Vec2D( 2.5, 2.711), {'visible':false, 'borderColor':'white', 'fillColor':'black'});
            p3 = new cP.Pin( new cP.Vec2D( 3.0, 3.577), {'visible':false, 'borderColor':'white', 'fillColor':'black'});
            new cP.Spring( aT.puckMap['puck1'], p1, centeringSpringPars);
            new cP.Spring( aT.puckMap['puck2'], p2, centeringSpringPars);
            new cP.Spring( aT.puckMap['puck3'], p3, centeringSpringPars);
         }
         
         dC.extraDemos.innerHTML = 
            "<a title='color mixer' " + hL('9.a') + " onclick=\"gW.clearState(); gW.demoStart(9)\">&nbsp;a,</a>" +
            "<a title='colorful' " + hL('9.b') + " onclick=\"gW.demoStart_fromCapture(9, {'fileName':'demo9b.js'})\">&nbsp;b&nbsp;</a>";
         
      }
      
      // If any demo uses special canvas dimensions, now is a good time to let the clients know.
      // (note: this canvas resize is different from the chat div resizing that is done for demo 8).
      setClientCanvasToMatchHost();
      logEntry( c.demoVersion);
      
      // If no nickname, put the tip back into the chat input field.
      if ( ! (clients['local'].nickName)) hC.restoreInputDefault( document.getElementById('inputField'));
      
      // Sometimes just want to be sure the user gets the fullscreen view.
      if (c.fullScreenDemo) {
         hC.changeFullScreenMode( canvas, 'on');
      }
      
      // For example, the dandelion demos, turn shooter on for each client, and set the shot speed.
      if (c.lockedAndLoaded ) {    
         cP.Client.applyToAll( client => {
            if (c.demoVersion.slice(0,3) == "5.d") {
               client.ctrlShiftLock = true;
               client.poolShotLocked = true;
               client.poolShotLockedSpeed_mps = 200;
               
            } else if (c.demoVersion.includes('basketball')) {
               client.ctrlShiftLock = true;
            }
         });
      }   
      
      console.log('c.demoVersion=' + c.demoVersion);
   }
   
   
   ///////////////////////////////////////////////////////
   // Initialize almost everything ///////////////////////
   ///////////////////////////////////////////////////////

   /*   
   init() is called from index.html after the page load. This delays the 
   execution of init() until after all the page elements have loaded in. 
   For good reason, listeners are initialize here, after the delay, so that 
   the corresponding page elements exist. Note that because of this delay, 
   no objects can be initialized here that need to be revealed in the 
   public pointers at the end of this file. The key_ctrl_handler is an 
   example where the function CAN be defined outside of init (i.e. no 
   direct reference to page elements), and MUST be defined outside of init 
   (i.e. needs to be exported). 

      
   It should be noted that the maps (like aT.springMap which is defined 
   above) appear not to be available for use in other modules until the 
   index page fully loads and init() has run. This surprised me a little to 
   see it. I would have thought once the maps were defined, although empty 
   {} objects, not yet populated, they would immediately be available to be 
   revealed in the return object at the end of the module. I noticed this 
   when adding a testing call to Spring.applyToAll() in the MultiSelect 
   resetAll prototype. If you delay that call (via window.setTimeout) it 
   runs without error. I left it there, delayed and commented. 
   */
   
   function init() {
      
      // Demo specified in URL query string.
      // Take the first part of the string (ignore, for now, anything after the & character).
      var queryStringInURL = window.location.search.split("&")[0];
      var demoFromURL = {};
      var scrollTargetAtStart = null;
      
      // e.g. www.timetocode.org/?7
      if (queryStringInURL.length == 2) {
         demoFromURL.index = queryStringInURL.slice(1,2);
      
      } else if (queryStringInURL.length >= 3) {
         // for a special version of the demo, e.g. demo5d or demo5d.fullscreen
         // e.g. www.timetocode.org/?7b  or  www.timetocode.org/index.html?3d.8ball
         if ((queryStringInURL.length == 3) || queryStringInURL.includes(".")) {
            // Take everything after the ?
            demoFromURL.file = 'demo' + queryStringInURL.slice(1) + '.js';
            // Take only the first character after the ?
            demoFromURL.index = queryStringInURL.slice(1,2);
            
         // Open to a particular help topic, e.g. www.timetocode.org/?codeLinks
         } else {
            scrollTargetAtStart = "#" + queryStringInURL.slice(1);
         }
      }
            
      // Initialize the canvas display window.
      
      myRequest = null;
      resumingAfterPause = false;
      time_previous = performance.now(); // Initialize the previous time variable to now.
      canvas = document.getElementById('hostCanvas');
      canvasDiv = document.getElementById('hostCanvasDiv');
      
      ctx = canvas.getContext('2d');
      
      // Miscellaneous pointers to DOM elements
      
      dC.json = document.getElementById('jsonCapture');
            
      /////////////////////////////////////////////////////
      // Event handlers for local client (user input)
      /////////////////////////////////////////////////////
      
      // Inhibit the context menu that pops up when right clicking (third button).
      // Do this on mainDiv to prevent the menu from appearing when you drag the
      // mouse off the canvas.
      var mainDiv = document.getElementById('mainDiv');
      mainDiv.addEventListener("contextmenu", function(e) {
         e.preventDefault();
         return false;
      }, {capture: false});
      
      /*
      // Added this (11:17 AM Fri May 29, 2020) as workaround to a Chromium bug.
      // https://bugs.chromium.org/p/chromium/issues/detail?id=1087488
      // Should not need this!
      // Bug is fixed in Chrome Version 83.0.4103.97
      const resizeHandler = new ResizeObserver( entries => {
         for (let entry of entries) {
            const cr = entry.contentRect;
            console.log('Element:', entry.target.id);
            console.log(`Element size: ${cr.width}px x ${cr.height}px`);
            if (entry.target.id == 'hostCanvas') {
               canvasDiv.style.width = cr.width + "px";
               canvasDiv.style.height = cr.height + "px";
            }
         }
      });
      resizeHandler.observe( canvas);
      */
      
      wheelEvent_handler = function(clientName, e) {
         var client = clients[ clientName];
         
         // Adjust pool-shot speed value.         
         if (client.poolShotLocked) {
            client.poolShotLockedSpeed_mps = Math.round( client.poolShotLockedSpeed_mps);
            if (e.deltaY < 0) {
               client.poolShotLockedSpeed_mps += 1.0;
            } else {
               client.poolShotLockedSpeed_mps -= 1.0;
            }
            messages['help'].newMessage(client.nameString() + ", shot speed locked: [25px Arial,yellow]" + client.poolShotLockedSpeed_mps.toFixed( 1) + "[base] mps", 1.0);
         }
      }
      
      document.addEventListener('visibilitychange', function(e) {
         if (document.visibilityState !== 'hidden') {
            console.log("window restored");
            restartAnimationLoop( 800);
         } else {
            console.log("window minimized");
         }
      });

      document.addEventListener('wheel', function(e) {
         // note: see comments in the wheel event listener in hostAndClient.js 
         // Thu May 20, 2021, had to put the wheel listener on the document (window also works) for this event to fire when canvas is fullscreen.
         if (clients['local'].poolShotLocked) e.preventDefault();
         wheelEvent_handler('local', e);
      }, {passive: false, capture: false});
      
      // Note: This call to addEventListener for mousemove could be put (and was for a while) inside the mousedown handler. 
      // Then, if there is a corresponding removeEventListen for this in the mouseup handler, effectively the
      // the mousemove listener would only run while a mouse button is down. That works out 
      // nicely if you are using the native Windows cursor. But if you are drawing a cursor into
      // the canvas, you need to keep track of mouse position even if the mouse isn't clicked down.
      document.addEventListener("mousemove", function(e) { 
         handleMouseOrTouchMove( e, 'mousemove');
      }, {capture: false});
      
      clickToClearMulti = function(clientName) {
         var client = clients[ clientName];
         
         // Check for body at the mouse position. If nothing there, and shift (and alt) keys are UP, reset the
         // multi-select map. So, user needs to release the shift (and alt) key and click on open area to 
         // flush out the multi-select.
         var selected_b2d_Body = b2d_getBodyAt( client.mouse_2d_m);
         var selectedBody = tableMap.get( selected_b2d_Body);
            
         if ((client.key_shift == "U") && (client.key_alt == "U") && (client.key_ctrl == "U")) {
            // Un-select all the springs.
            cP.Spring.findAll_InMultiSelect( spring => spring.selected = false);
            cP.Joint.findAll_InMultiSelect( joint => joint.selected = false);
            
            // Clicked on blank space on air table (un-selecting everything)
            if ( ! selected_b2d_Body) {
               // Un-select everything in the multi-select map.
               hostMSelect.resetAll();
               cP.Client.deleteModeIndex = 0;
            }
         } 
      }
      
      canvas.addEventListener("mousedown", function(e) {
         clients['local'].mouseDown = 'M';
         
         // If there's been a click inside the canvas area, flag it as mouse usage for the local user (host).
         if ( pointInCanvas( clients['local'].mouse_2d_px) ) {
            clients['local'].mouseUsage = true;
         }
         
         clients['local'].button = e.button;
         
         /* for making videos... 
         if (clients['local'].button == 0) {
            messages['lowHelp'].newMessage('[base,yellow]left[base,lightgray] mouse button', 20.0);
         } else if (clients['local'].button == 1) {
            messages['lowHelp'].newMessage('[base,yellow]middle[base,lightgray] mouse button', 20.0);
         } else if (clients['local'].button == 2) {
            messages['lowHelp'].newMessage('[base,yellow]right[base,lightgray] mouse button', 20.0);
         }
         */
         
         // Pass this first mouse position to the move handler. This will establish
         // the world position of the mouse.
         handleMouseOrTouchMove( e, 'mousedown');
      
         // (Note: also see the checkForMouseSelection method in the cP.Client prototype.)
         // Clear out the multi-select map, if user clicks in open area.
         clickToClearMulti('local');
         
         // start a cursor-based selection box (host only)
         if ((clients['local'].key_alt == 'D') && (clients['local'].key_ctrl == 'U') && ([0,1,2].includes(clients['local'].button)) && (!hostSelectBox.enabled)) {
            hostSelectBox.start();
            hostSelectBox.update();
         }
         
         // This prevents the middle mouse button from doing scrolling operations.
         e.preventDefault();
                  
      }, {capture: false});
      
      canvas.addEventListener("touchstart", function(e) {
         // Note: e.preventDefault() not needed here if the following canvas style is set
         // touch-action: none;
         
         clients['local'].mouseDown = true;
         clients['local'].button = 0;
         clients['local'].touchScreenUsage = true;
         
         //Pass this first mouse position to the move handler.
         handleMouseOrTouchMove( e, 'touchstart');
         
      }, {passive: true, capture: false});
      
      function handleMouseOrTouchMove( e, fromListener) {
         // Mouse
         if (e.clientX || (e.clientX === 0)) {  // note the "=== 0" here, because x can be zero, and 0 is falsy.
            var raw_2d_px = new cP.Vec2D( e.clientX, e.clientY );
         // Touch
         } else if (e.touches) {
            var raw_2d_px = new cP.Vec2D( e.touches[0].clientX, e.touches[0].clientY ); // new cP.Vec2D(0,0);            
            gB.interpretTouches( e, 'start', 'host', clients['local'], null, fromListener, null, ts, raw_2d_px);
         }
         
         clients['local'].raw_2d_px = raw_2d_px;
         
         //var debugString = "in handler:" + raw_2d_px.x + "," + raw_2d_px.y;
         //hC.sendSocketControlMessage( {'from':'anyone', 'to':'host', 'data':{'androidDebug':{'value':true,'debugString':debugString}} } );
         
         // Always use 'mouse' for inputDevice, and avoid the stretching of the x,y (call to stretchRaw_px inside of screenFromRaw_2d_px), 
         // which is mainly useful for cell-phone network clients.
         var posOnCanvas_2d_px = screenFromRaw_2d_px( canvas, raw_2d_px, {'inputDevice':'mouse', 'demoRunningOnHost':c.demoVersion});
         
         // facilitate high-resolution cursor movements
         var finalPosOnCanvas_2d_px = fineMoves('local', posOnCanvas_2d_px);
         if (clients['local'].fineMovesState != 'inTransition') {
            clients['local'].mouse_async_2d_px = finalPosOnCanvas_2d_px;
            if (c.lagTesting) dF.drawCircle( ctx, finalPosOnCanvas_2d_px, {'borderWidth_px':0, 'fillColor':'cyan', 'radius_px':3});
         }
      };
      
      mouseUp_handler = function( clientName) {
         resetMouseOrFingerState( clientName);
         
         var client = clients[ clientName];
         
         var selectedPuckName = null;
         if (client.selectedBody) {
            // If you're the owner of direct movement, remember the puck name, so can release the choke (after the pool shot).
            if ((client.name == client.selectedBody.firstClientDirectMove) && (client.selectedBody.constructor.name == "Puck")) {
               selectedPuckName = client.selectedBody.name;
            }
         }
         
         if (client.cursorSpring) {
            // Shoot the (single-selected) puck with the cursor spring energy.
            var tryingToShoot = ((client.key_ctrl == 'D') && (client.key_shift == 'D')) || (client.ctrlShiftLock);
            if ((tryingToShoot) && (client.selectedBody)) {
               if (client.selectedBody.constructor.name == 'Puck') {
                  // This restriction on shooting is a way for the user to NOT shoot (cancel a shot):
                  //    move the cursor inside the "cue" ball before launching it.
                  var selected_b2d_Body = b2d_getBodyAt( client.mouse_2d_m);
                  var selectedBody = tableMap.get( selected_b2d_Body);
                  messages['help'].resetMessage(); // stop the help for experienced pool players
                  if ((!selectedBody) || ((selectedBody) && (selectedBody.name != client.selectedBody.name))) {
                     
                     // Only the first client to select the puck for direct movement (i.e. rotation) can shoot.
                     if (client.name == client.selectedBody.firstClientDirectMove) {
                        if (client.cursorSpring.p1p2_separation_m > 0.01) {
                           gB.poolShot( client);
                           
                        } else {
                           messages['help'].newMessage("shot prevented:" + 
                                                    "\\    not enough separation between the ghost and cue ball (touching, or nearly so)" +
                                                    "\\ \\        try moving the ghost to the other side of the object ball before shooting" +
                                                    "\\ \\        or try dragging the ghost away from the object ball and then shoot with the alt key down", 10.0);         
                        }
                     }
                  } else {
                     // report if not ball-in-hand
                     if (client.key_ctrl != 'D') messages['help'].newMessage("shot canceled", 1.5);          
                  }
               }
            }
            client.modifyCursorSpring('dettach');
            
            client.fineMovesState = 'off'; // startTransition
         }
         
         // Now, after possibly shooting the puck (and detaching from it), release the direct-move choke.
         if (aT.puckMap[ selectedPuckName]) {
            aT.puckMap[ selectedPuckName].firstClientDirectMove = null;
         }
         
         // Close the selection box.
         hostSelectBox.stop();
         
         // Done with rotation action.
         hostMSelect.resetCenter();
         
         // just to sure, clear out the cursor sensor
         client.sensorTargetName = null;
         client.sensorContact = null;
      }
         
      document.addEventListener("mouseup", function(e) {
         
         // Remove focus from checkboxes after use (release mouse button). This is needed for 
         // the canvas to get immediate attention when using the control, shift, and alt keys.
         // (for example: multi-select using the alt key after enabling the editor)
         $(":checkbox").blur();
         
         // This is necessary for MS Edge. Some buttons were staying depressed.
         $(":button").blur();
         
         // To get past here, mouseDown state must be down (true).
         if ( ! clients['local'].mouseDown) return;
         
         // See corresponding help message in mousedown handler.
         if (messages['lowHelp'].message.includes('mouse button')) {
            // clear out message from mousedown
            messages['lowHelp'].newMessage('', 0.1);
         }
         
         mouseUp_handler('local');
         
      }, {capture: false});
      
      /*
      Tried to use e.preventDefault() in touchmove to avoid android Firefox scrolling issues for the host page. 
      But when go fullscreen, can't center the view... Might be a timing issue. Had to use a delay to make the TwoThumbs grid render
      with Firefox in fullscreen mode. So for now, commenting preventDefault. Also considered starting touchmove
      in the touchstart event handler. But seems like it might fire multiple times with multiple touch points, unless add
      on first touch and remove when last touch is released.
      */
      canvas.addEventListener("touchmove", function(e) {
         //e.preventDefault();
         handleMouseOrTouchMove( e, 'touchmove');
      }, {capture: false});
      
      canvas.addEventListener("touchend", function(e) {
         gB.interpretTouches( e, 'end', 'host', clients['local'], null, 'touchend', null, ts, null);
         
         // Note: e.preventDefault() not needed here if the following canvas style is set
         // touch-action: none;
         
         if (clients['local'].mouseDown) {
            return;
            
         } else {
            //resetMouseOrFingerState('local');
            mouseUp_handler('local');
         }
         
      }, {passive: true, capture: false});
      
      function resetMouseOrFingerState( clientName) {
         var client = clients[ clientName];
         
         client.mouseDown = false;
         client.button = null;         
         client.mouseX_m = null;
         client.mouseY_m = null;  
      }
          
      var editKeysMap = {'key_leftArrow':'thinner', 'key_rightArrow':'wider', 'key_upArrow':'taller', 'key_downArrow':'shorter',
                          'key_[':'lessDamping', 'key_]':'moreDamping',
                          'key_-':'lessFriction',  'key_+':'moreFriction',
                          'key_-_':'lessFriction', 'key_=+':'moreFriction',
                          'key_lt':'lessDrag',     'key_gt':'moreDrag'};
      var allowDefaultKeysMap = {'key_-':null, 'key_+':null, 'key_-_':null, 'key_=+':null};
      
      document.addEventListener("keydown", function(e) {
         // Uncomment the following line for an easy test to see if the default key behavior can be inhibited.
         //e.preventDefault();
         
         //console.log(e.keyCode + " down/repeated, " + keyMap[e.keyCode]);
         
         // The following is necessary in Firefox to avoid the spacebar from re-clicking 
         // page controls (like the demo buttons) if they have focus.
         // This also prevents some unwanted spacebar-related button behavior in Chrome.
         if ((document.activeElement.tagName != 'BODY') && (document.activeElement.tagName != 'INPUT')) {
            document.activeElement.blur();
         }
         
         /*
         Anything in this first group of blocks will repeat if the key is held down for a 
         while. Holding it down will fire the keydown event repeatedly. Of course 
         this area only affects the local client. Note there is another area in 
         this code where repetition is avoided though use of the key_?_enabled 
         attributes; search on key_s_enabled for example. That repetition is of 
         a different nature in that it comes from action triggered by observing 
         the key state (up/down) each frame. 
         */
         
         // Note: the activeElement clause avoids acting on keystrokes while typing in the input cells in MS Edge.
         if ((e.keyCode in keyMap) && (document.activeElement.tagName != 'INPUT')) {
            // If you want down keys to repeat, put them here.
            
            // Inhibit default behaviors.
            if (['key_space', 'key_s', 'key_q', 'key_alt', 'key_questionMark', 'key_tab'].includes( keyMap[e.keyCode])) {
               // Inhibit page scrolling that results from using the spacebar (when using puck shields)
               // Also inhibit repeat presses of the demo keys when using the spacebar.
               // Inhibit ctrl-s behavior in Firefox (save page).
               // Inhibit ctrl-q behavior in Edge (history panel).
               // Inhibit questionMark key behavior in Firefox (brings up text-search box)
               // Inhibit alt key behavior. Prevents a problem where if the alt key is depressed during the middle of a mouse drag, it
               //    prevents the box select from working on the next try.
               // Inhibit tabbing: jumping to each of the GUI controls on the page.
               e.preventDefault();
                
            } else if ((keyMap[e.keyCode] in editKeysMap) && !(keyMap[e.keyCode] in allowDefaultKeysMap)) {
               // Prevent page scrolling when using the arrow keys in the editor.
               e.preventDefault();
            
            } else if (keyMap[e.keyCode] == 'key_o') {
               if (! dC.pause.checked) {
                  setElementDisplay("fps_wrapper", "none");
                  setElementDisplay("stepper_wrapper", "inline");
               }
               stepAnimation();
            
            // Change body rotation when editing.
            } else if ((keyMap[e.keyCode] == 'key_t')) {
               hostMSelect.applyToAll( tableObj => {
                  if (clients['local'].key_shift == 'D') {
                     // Increase rate counterclockwise
                     var rotRate_change_dps = +5; // degrees per second
                  } else {
                     // clockwise
                     var rotRate_change_dps = -5;
                  }
                  var rotRate_change_rps = rotRate_change_dps * (Math.PI/180); // radians per second
                  
                  tableObj.angularSpeed_rps += rotRate_change_rps;
                  // if not a static body type
                  if (tableObj.b2d.m_type != b2Body.b2_staticBody) {
                     // If you change the rate so that it stops the rotation, or if the body is initially not rotating, it
                     // will be sleeping. In those cases, must wake it before setting the angular speed.
                     if ( ! tableObj.b2d.IsAwake()) tableObj.b2d.SetAwake( true);
                     tableObj.b2d.SetAngularVelocity( tableObj.angularSpeed_rps);
                  }
               });
            }
            
            // Use the keys in the edit-keys map to change the characteristics of the selected body.
            if (keyMap[e.keyCode] in editKeysMap) {
               var command = editKeysMap[ keyMap[e.keyCode]];
               
               function modifyPuckCommand( tableObject, command) {
                  // The host can use the alt key to modify angular drag on pucks...
                  if ((tableObject.constructor.name == "Puck") && (clients['local'].key_alt == 'D')) {
                     if (command == "moreDrag") {
                        command = "moreAngDrag";
                     } else if (command == "lessDrag") {
                        command = "lessAngDrag";
                     } 
                  }
                  return command;
               }
               
               function modifySpringCommand( command) {
                  // The host can use the alt key to modify the spring command...
                  if (clients['local'].key_alt == 'D') {
                     if (command == "wider") {
                        command = "widerAppearance";
                     } else if (command == "thinner") {
                        command = "thinnerAppearance";
                     } 
                  }
                  return command;
               }
               
               // Multi-select
               if (hostMSelect.count() > 0) {
                  // Direct the edit actions at the springs (s key down)
                  if (clients['local'].key_s == 'D') {
                     // Arrow keys and page-up/page-down.
                     cP.Spring.findAll_InMultiSelect( spring => spring.interpret_editCommand( modifySpringCommand( command)));
                  // All other object types
                  } else {
                     hostMSelect.applyToAll( msObject => {
                        if (msObject.constructor.name != "Pin") {
                           command = modifyPuckCommand( msObject, command);
                           msObject.interpret_editCommand( command);
                        }
                     });
                  }
                  
               // Single-body selection (client spring)
               } else if (clients['local'].selectedBody) {
                  if (clients['local'].selectedBody.constructor.name != "Pin") {
                     command = modifyPuckCommand( clients['local'].selectedBody, command);
                     clients['local'].selectedBody.interpret_editCommand( command);
                  }
               }
            }
            
            /*
            Keys that are held down will NOT repeat in this next block. Current key 
            state must be UP before it will change the state to DOWN and perform the 
            action. This is for cases where you are toggling the state of the 
            client's key parameter. Also see comment paragraph on repetition above.
            
            Note: when a Client object is initialized, all it's key values are set to 'UP'.
            */
            
            // If the current key state is UP...
            if (clients['local'][keyMap[e.keyCode]] == 'U') {
               
               // Set the key state to be DOWN.
               clients['local'][keyMap[e.keyCode]] = 'D';
               
               // Immediate execution on keydown (that's the event that got you in here):
               
               if (keyMap[e.keyCode] == 'key_ctrl') {
                  key_ctrl_handler('keydown', 'local');
                  
               } else if (keyMap[e.keyCode] == 'key_l') {
                  key_l_handler('keydown', 'local');
               
               // For showing all the count-to-pi demos without exiting full-screen view (for making videos).
               } else if ((clients['local'].key_alt == 'D') && (keyMap[e.keyCode] == 'key_pageDown')) {
                  if (c.demoLoopIndex == 0) {
                     demoStart_fromCapture(1, {'fileName':'demoSeries1b.js'});
                  } else if (c.demoLoopIndex == 1)  {
                     demoStart_fromCapture(1, {'fileName':'demoSeries1c.js'});
                  } else if (c.demoLoopIndex == 2)  {
                     demoStart_fromCapture(1, {'fileName':'demoSeries1d.js'});
                  } else if (c.demoLoopIndex == 3)  {
                     demoStart_fromCapture(1, {'fileName':'demoSeries1e.js'});
                  }
                  if (c.demoLoopIndex == 3) {
                     c.demoLoopIndex = 0;
                  } else {
                     c.demoLoopIndex += 1;
                  }
                  
               } else if ((keyMap[e.keyCode] == 'key_a') && (clients['local'].key_ctrl == 'D')) {
                  c.drawSyncImage = ( ! c.drawSyncImage);
                  cP.Client.applyToAll( client => {client.sendDrawSyncCommand = true;});
                                 
               } else if ((keyMap[e.keyCode] == 'key_b')) {
                  key_b_handler('local');
                  
               } else if ((keyMap[e.keyCode] == 'key_c') && (clients['local'].key_ctrl != 'D')) {
                  key_c_handler('local');
                  
               } else if ((keyMap[e.keyCode] == 'key_backspace') && (clients['local'].key_ctrl == 'D')) {
                  reverseDirection();
                  messages['help'].newMessage('translation and rotation have been [base,yellow]reversed[base]', 0.5);
                  
               } else if (keyMap[e.keyCode] == 'key_f') { 
                  if (clients['local'].key_alt == 'D') {
                     // nothing here yet
                  } else {
                     freeze();
                     messages['help'].newMessage('[base,yellow]translation[base] has been momentarily [base,yellow]stopped[base]', 1.0);
                  }
                  
               } else if (keyMap[e.keyCode] == 'key_r') { 
                  if (clients['local'].key_alt == 'U') {
                     stopRotation();
                     messages['help'].newMessage('[base,yellow]rotation[base] has been momentarily [base,yellow]stopped[base]', 0.5);
                     
                  } else if (clients['local'].key_alt == 'D') {
                     addRevoluteJoint();
                  }
               
               } else if (keyMap[e.keyCode] == 'key_g') { 
                  c.g_ON = !c.g_ON;
                  if (c.g_ON) {
                     dC.gravity.checked = true;
                  } else {
                     dC.gravity.checked = false;
                  }
                  setGravityRelatedParameters({'showMessage':true});
                  /*
                  // If there is only one fixture, m_fixtureList (a linked list) is a reference to that single fixture.
                  console.log(' ');
                  console.log("fixture count=" + aT.wallMap['wall1'].b2d.m_fixtureCount);
                  // also might want to look here: m_fixtureList, m_fixtureList.m_shape, m_fixtureList.m_shape.m_vertices
                  for (var x in aT.wallMap['wall1'].b2d.m_fixtureList) {
                     console.log("name=" + x);
                  }
                  */               
               } else if (keyMap[e.keyCode] == 'key_m') { 
                  $('#chkMultiplayer').trigger('click');
                  
               } else if (keyMap[e.keyCode] == 'key_n') { 
                  key_n_handler('local');
                  
               } else if (keyMap[e.keyCode] == 'key_u') { 
                  saveState();
                  messages['help'].newMessage('state has been [base,yellow]captured[base]', 0.5);
                  
               } else if ((keyMap[e.keyCode] == 'key_v') && (clients['local'].key_ctrl != 'D')) { 
                  hC.changeFullScreenMode( canvas, 'on');
                  
               } else if (keyMap[e.keyCode] == 'key_e') { 
                  dC.editor.checked = !dC.editor.checked;
                  toggleEditorStuff();
               
               } else if ((keyMap[e.keyCode] == 'key_p') && (clients['local'].key_shift != 'D') && (clients['local'].key_alt != 'D')) { 
                  dC.pause.checked = !dC.pause.checked;
                  setPauseState();
               
               } else if ((keyMap[e.keyCode] == 'key_p') && (clients['local'].key_alt == 'D')) { 
                  clearCanvas();
                  c.pauseErase = ! c.pauseErase;
                  if ((c.pauseErase) && (c.demoVersion.slice(0,3) == "3.d")) {
                     // For recording the ball paths of a pool shot. This triggers a shot when the pauseErase is set true.
                     clients['local'].key_alt = 'U';
                     mouseUp_handler('local');
                  }
                  if ( ! c.pauseErase) messages['help'].newMessage( 'screen erasing is [base,yellow]ON[base]', 0.5);
               
               // Toggle the default spring type
               } else if ((keyMap[e.keyCode] == 'key_s') && (clients['local'].key_shift == 'D')) {
                  c.softConstraints_default = !c.softConstraints_default;
                  if (c.softConstraints_default) {
                     messages['help'].newMessage("new springs: [base,yellow]distance joint[base] with soft constraints", 2.0);
                  } else {
                     messages['help'].newMessage("new springs: [base,yellow]Hooke's Law[base]", 2.0);
                  }
                  if (Object.keys(aT.springMap).length > 0) messages['help'].addToIt("\\ \\   existing springs:");
                  if (clients['local'].key_alt == 'D') {
                     cP.Spring.applyToAll( spring => {
                        
                        delete spring.softConstraints;
                        spring.softConstraints_setInPars = false;
                        
                        if (spring.b2d) {
                           gW.world.DestroyJoint( spring.b2d);
                           spring.b2d = null;
                        }

                        messages['help'].addToIt("\\     " + spring.name + " softConstraint key has been [base,yellow]deleted[base].");
                     });
                  } else {
                     cP.Spring.applyToAll( spring => {
                        let springNature;
                        if (typeof spring.softConstraints === "undefined") {
                           springNature = "[18px Arial,yellow]Hooke's law[18px Arial]";
                        } else {
                           let fixedString = (spring.softConstraints_setInPars) ? " (locked)" : "";
                           if (spring.softConstraints) {
                              springNature = "a [18px Arial,yellow]distance joint[18px Arial] with soft constraints" + fixedString;
                           } else {
                              springNature = "[18px Arial,yellow]Hooke's law[18px Arial]" + fixedString;
                           }
                        }
                        messages['help'].addToIt("\\[18px Arial,lightgray]     " + spring.name + " spring nature is " + springNature + "[base].");
                     });
                  }
                  
               
               // Toggle the lock on the poolshot and set the speed value (z key pressed, while control and shift are down).
               } else if ( (keyMap[e.keyCode] == 'key_z') && (((clients['local'].key_shift == 'D') && (clients['local'].key_ctrl == 'D')) || (clients['local'].ctrlShiftLock)) ) {
                  gB.togglePoolShotLock( clients['local']);
               
               // Pause NPC navigation.
               } else if ((keyMap[e.keyCode] == 'key_q') && (clients['local'].key_ctrl == 'D')) {
                  pP.setNpcSleep( ! pP.getNpcSleep());
                  if (pP.getNpcSleep()) {
                     // Keep track of this during game play.
                     pP.setNpcSleepUsage( true);
                     messages['help'].newMessage("drones are [base,yellow]sleeping[base]", 1.0);
                     // Make sure their i keys are up, i.e. stop trying to shoot (and calling the bullet stream updater).
                     cP.Client.applyToAll( client => {if (client.name.slice(0,3) == 'NPC') client.key_i = "U" });
                  } else {
                     messages['help'].newMessage("drones are [base,yellow]awake[base]", 1.0);
                  }
                  
               // set delete mode   
               } else if ((keyMap[e.keyCode] == 'key_tab') && (hostMSelect.count() > 1)) {
                  if (cP.Client.deleteModeIndex < 3) {
                     cP.Client.deleteModeIndex++;
                  } else {
                     cP.Client.deleteModeIndex = 0;
                  }
                  messages['help'].newMessage("delete mode: [base,yellow]" + cP.Client.deleteMode[ cP.Client.deleteModeIndex] + "[base]", 1.0);
                  
               // Delete stuff   
               } else if ((keyMap[e.keyCode] == 'key_x') && (clients['local'].key_ctrl == 'D')) {
                  
                  // First process multi-select
                  var foundSpringOrJoint = false;
                  let deleteMode = cP.Client.deleteMode[ cP.Client.deleteModeIndex].split(" ")[0];
                  
                  if (hostMSelect.count() > 0) {
                     
                     if (['normal','springs','everything'].includes( deleteMode)) {
                        // Delete each spring that has both it's pucks (or pins) in the multi-select.
                        cP.Spring.findAll_InMultiSelect( spring => {
                           spring.deleteThisOne({});
                           // This function includes the scope of the function in which it is being defined.
                           // So foundSpringOrJoint, defined in the surrounding function, is accessible (and changeable) here.
                           foundSpringOrJoint = true; 
                        });
                     }
                     
                     if (['normal','revolute','everything'].includes( deleteMode)) {
                        // Delete each revolute joint that has both it's pucks in the multi-select.
                        cP.Joint.findAll_InMultiSelect( joint => {
                           joint.deleteThisOne({});
                           foundSpringOrJoint = true;
                        });
                     }
                     
                     // If springs have been cleared during first delete, now remove pucks, pins and walls that are still selected.
                     if ( (['normal'].includes( deleteMode) && ( ! foundSpringOrJoint)) || (['everything'].includes( deleteMode)) ) {
                        hostMSelect.applyToAll( msObject => msObject.deleteThisOne({}) );
                     }
                     
                  } else if (clients['local'].selectedBody) {
                     // A single-object selection.
                     if (clients['local'].selectedBody.constructor.name == 'Puck') clients['local'].deleteBox2dSensor();
                     clients['local'].selectedBody.deleteThisOne({'deleteMode':'fromEditor'}); // Pucks, pins, and walls all have there own version of this method.
                     clients['local'].selectedBody = null;
                     clients['local'].cursorSpring.deleteThisOne({});
                     clients['local'].cursorSpring = null;
                  }
                  
               // Identify a spring for copying.
               } else if ((keyMap[e.keyCode] == 'key_c') && (clients['local'].key_ctrl == 'D')) {
                  if ((hostMSelect.count() > 0) && (hostMSelect.count() != 2)) {
                     messages['help'].newMessage( hostMSelect.count() + " selected; need 2 to select a spring", 1.0);
                  }
                  // Clear this out each time ctrl-c is used.
                  cP.Spring.nameForPasting = null;
                  
                  // Two bodies in the multi-select, so maybe a spring in there...
                  if (hostMSelect.count() == 2) {
                     // Note: this "no spring" message will be overwritten (immediately) if a spring is found in the block below.
                     messages['help'].newMessage("2 selected, but no spring", 1.0);
                     cP.Spring.findAll_InMultiSelect( spring => {
                        // Make a reference to the spring. If there is more than one spring attached, this will reference the last one.
                        // Rendering characteristics will be different for this source puck.
                        cP.Spring.nameForPasting = spring.name;
                        messages['help'].newMessage("[25px Arial,yellow]" + spring.name + "[base] will be used as the source spring for copy and paste.", 3.0);
                        // De-select all the springs on these two pucks (so the user doesn't have to click on empty space).
                        spring.selected = false;
                     });
                     // Added this mainly to be used in probing the name of a joint. Not used for copying the joint. Revolve joints are always added via the pull-down menu.
                     cP.Joint.findAll_InMultiSelect( joint => {
                        let jointMessage = "[20px Arial,yellow]" + joint.name + "[base] joint has been deselected.";
                        if (cP.Spring.nameForPasting) {
                           messages['help'].addToIt('\\  ' + jointMessage, {'additionalTime_s':2.0});
                        } else {
                           messages['help'].newMessage( jointMessage, 3.0);
                        }
                        // De-select joints
                        joint.selected = false;
                     });
                     
                     hostMSelect.resetAll();
                     
                  // Give help to user if a single body is selected directly with the cursor.
                  } else if (clients['local'].selectedBody) {
                     // Put this message lower on the screen than the normal help because use of the control key
                     // will display a help message listing puck attributes.
                     messages['lowHelp'].newMessage("If you would like to replicate a single object, try ctrl-v.", 1.0);
                  }
               
               // Paste a spring onto a pair of pucks.
               } else if (keyMap[e.keyCode] == 'key_s') {
                  if (clients['local'].key_ctrl == 'D') {
                     if (clients['local'].key_alt == 'U') {
                        // paste spring that is to be copied. 
                        pasteSpring( false);
                     } else if (clients['local'].key_alt == 'D') {
                        // paste a new (default) spring.
                        pasteSpring( true);
                     }
                  }
               
               // A general copy and paste of the selected bodies.
               } else if ((keyMap[e.keyCode] == 'key_v') && (clients['local'].key_ctrl == 'D')) {
                  // Single object or a group as selected using the techniques of multiselect.
                  if (hostMSelect.count() > 0) {
                     hostMSelect.pasteCopyAtCursor();
                  
                  // A single object as selected using single select (direct host-cursor selection)
                  } else if (clients['local'].selectedBody) {
                     var cn = clients['local'].selectedBody.constructor.name;
                     if ((cn == "Wall") || (cn == "Pin") || (cn == "Puck")) {
                        // Put the copy a little to the right of the original. The engine will separate them
                        // if they overlap (colliding).
                        var pos_forCopy_2d_m = clients['local'].selectedBody.position_2d_m.addTo( new cP.Vec2D(0.1, 0.0));
                        clients['local'].selectedBody.copyThisOne({'position_2d_m':pos_forCopy_2d_m});
                     }
                  }
                     
               } else if ((clients['local'].key_shift == 'D') && (clients['local'].key_p == 'D') && (clients['local'].key_d == 'D')) {
                     // Make a single-pin drone track at the cursor location (for Puck Popper demos only).
                     if (c.demoIndex == 7 || c.demoIndex == 8) {
                        pP.makeNPC_OnSinglePin(1, cP.Pin.nameIndex + 1, cP.Client.npcIndex + 1, clients['local'].mouse_2d_m);
                     } else {
                        messages['help'].newMessage('This feature is only available for demos 7 and 8 (Puck Popper).', 1.0);
                     }
                  
               // numbers 0 to 9, run a demo
               } else if ((e.keyCode >= 48) && (e.keyCode <= 57)) {
                  if (document.activeElement.tagName == 'BODY') {
                     demoStart(e.keyCode - 48, false);
                  }
               }
            }            
         }
      }, {capture: false}); //This "false" makes this fire in the bubbling phase (not capturing phase).
      
      
      // Note: these next functions have global scope (defined at beginning of this file) and can be used outside of init (e.g. in updateClientState)
      
      key_b_handler = function( clientName) {
         let client = clients[ clientName];
         let nameForHelp = client.nameString(true);
         
         if (client.key_alt == 'D') {
            gB.getTableCapture('previous');
            
         } else {
            let notPool = (c.demoVersion.slice(0,3) != "3.d");
            let poolWithGravityOn = ( (c.demoVersion.slice(0,3) == "3.d") && (c.g_ON) );
            if ( (client.fineMovesState == 'off') && (notPool || poolWithGravityOn) ) {
               client.fineMovesState = 'on';
               client.previousFine_2d_px = client.mouse_2d_px;
               messages['lowHelp'].newMessage("[base,lightgray]high-res positioning ("+ nameForHelp +"): [base,yellow]ON[base]", 1.0);
               
            } else if (client.fineMovesState == 'on') {
               client.fineMovesState = 'startTransition';
               messages['lowHelp'].newMessage("[base,lightgray]high-res positioning ("+ nameForHelp +"): [base,yellow]OFF[base]", 1.0);
               if (clientName == 'local') {
                  // do a sudo-mouse movement to trigger the finemove transition (the rewind back to the actual mouse position)
                  let raw_2d_px = rawScreenFromImagingElement( canvas, client.prevNormalCursorPosOnCanvas_2d_px, 5);
                  handleMouseOrTouchMove({'clientX':raw_2d_px.x, 'clientY':raw_2d_px.y});
               }
            }
         }
      }
      
      key_c_handler = function( clientName) {
         let client = clients[ clientName];
         
         // Center the attachment (or selection) point along the narrowest dimension of the selected object.
         // And if it's already centered (from the first alt-c), push the attachment point to the nearest end of the object (the second alt-c).
         function centerThePoint( selectedBody, cursorSelected) {
            if (selectedBody.shape != 'circle') {
               let aspectRatioTarget = 1.01;
               
               let aspectRatioWH = selectedBody.half_width_m / selectedBody.half_height_m;
               let helpString = "centered";
               
               let compForCentering, compForPushToEnd, distToEnd_m;
               if (aspectRatioWH > aspectRatioTarget) {
                  compForCentering = 'y';
                  compForPushToEnd = 'x';
                  distToEnd_m = selectedBody.half_width_m;
               } else if ((aspectRatioWH < 1.0/aspectRatioTarget)) {
                  compForCentering = 'x';
                  compForPushToEnd = 'y';
                  distToEnd_m = selectedBody.half_height_m;
               } else {
                  messages['lowHelp'].newMessage('[base,lightgray]Puck must be [base,yellow]rectangular[base,lightgray], not a perfect square.', 0.5);
                  return;
               }
               
               // push to the nearest end
               if (selectedBody.selectionPoint_l_2d_m[ compForCentering] == 0.0) {
                  let directionOfPush = (selectedBody.selectionPoint_l_2d_m[ compForPushToEnd] < 0) ? -1 : +1;
                  selectedBody.selectionPoint_l_2d_m[ compForPushToEnd] = directionOfPush * distToEnd_m;
                  helpString = "pushed to the end";
               }
               // center it along the narrowest component
               selectedBody.selectionPoint_l_2d_m[ compForCentering] = 0.0;
               
               // If client cursor selected, must also update these client attributes.
               if (cursorSelected) {
                  // push to the nearest end
                  if (client.selectionPoint_l_2d_m[ compForCentering] == 0.0) {
                     let directionOfPush = (client.selectionPoint_l_2d_m[ compForPushToEnd] < 0) ? -1 : +1;
                     client.cursorSpring.spo2_ap_l_2d_m[ compForPushToEnd] = directionOfPush * distToEnd_m;
                     client.selectionPoint_l_2d_m[ compForPushToEnd] = directionOfPush * distToEnd_m;
                     helpString = "pushed to the end";
                  }
                  // center it along the narrowest component
                  client.cursorSpring.spo2_ap_l_2d_m[ compForCentering] = 0.0;
                  client.selectionPoint_l_2d_m[ compForCentering] = 0.0;
               }
               
               messages['lowHelp'].newMessage('[base,lightgray]attachment points have been [base,yellow]' + helpString, 1.5);   
            }
         }
         
         if (client.key_alt == 'D') {
            let selectedBody = client.selectedBody;
            if (selectedBody) {
               centerThePoint( selectedBody, true);
               
            } else if (hostMSelect.count() > 0) {
               hostMSelect.applyToAll( msObject => {
                  centerThePoint( msObject, false);
               });
            }
            
         } else {
            // Only local host can change the COM selection checkbox.
            if (clientName == 'local') dC.comSelection.click();
         }
      }
      
      key_n_handler = function( clientName) {
         let client = clients[ clientName];
         
         if (client.key_alt == 'D') {
            gB.getTableCapture('next');
            
         } else if (clientName == 'local') {
            dC.fullCanvas.click();
         }
      }
      
      key_l_handler = function( mode, clientName) {
         let client = clients[ clientName];
         
         if (mode == 'keydown') {
            if ((client.key_ctrl == "D") && (client.key_shift == "D")) {
               client.ctrlShiftLock = !client.ctrlShiftLock;
               if (client.ctrlShiftLock) {var mS = 'ON'} else {var mS = 'OFF'};
               messages['help'].newMessage( clients[ clientName].nameString() + ' set ctrl-shift LOCK [base,yellow]' + mS + '[base]', 1.0);
            
            // alt-l : useful in ghost-ball for lining up trick shots
            } else if (client.key_alt == "D") {
               hostMSelect.align();
            }
         } else if (mode == 'keyup') {
         } else {
            console.log("not good to be in here...");
         }
      }
      
      pasteSpring = function( useNewSpring = false) {
         // p (a usefully short name) is an array of non-wall pucks or pins.
         var p = [];
         
         hostMSelect.applyToAll( msObject => {
            // Unselect the walls (don't allow the user to attach springs to the walls).
            if (msObject.constructor.name == 'Wall') {
               delete hostMSelect.map[ msObject.name];
            } else {
               // Populate the p array so you can pass the pucks and pins as parameters (see call to copyThisOne).
               p.push( msObject);
            }
         });
         
         // Only consider the case where there are two pucks (or pins) selected.
         if (hostMSelect.count() == 2) {
            var sameLocalPointsWarning = "";
            // Check each spring, between these two pucks in the multi-select, to see if trying to paste 
            // onto the same local attachment points of an existing spring (don't allow multiple springs on the same local points).
            // Note: originally needed to use the "areEqual" function at the module level, instead of the "equal" method because these point objects were sometimes
            // losing their methods. This was related to some vectors in restored captures not being fully Vec2D objects. That issue
            // has now (July 21, 2021) been resolved (see newBirth function).
            cP.Spring.findAll_InMultiSelect( spring => {
               if (( cP.Vec2D.areEqual( spring.spo1_ap_l_2d_m, p[0].selectionPoint_l_2d_m) &&  cP.Vec2D.areEqual( spring.spo2_ap_l_2d_m, p[1].selectionPoint_l_2d_m) ) ||
                   ( cP.Vec2D.areEqual( spring.spo2_ap_l_2d_m, p[0].selectionPoint_l_2d_m) &&  cP.Vec2D.areEqual( spring.spo1_ap_l_2d_m, p[1].selectionPoint_l_2d_m) ) ) {
                  sameLocalPointsWarning = spring.name; // already on target points
               }
            });
            
            if (useNewSpring) {
               if (sameLocalPointsWarning == "") {
                  let tempSpring = new cP.Spring(p[0], p[1], {'spo1_ap_l_2d_m': p[0].selectionPoint_l_2d_m, 'spo2_ap_l_2d_m': p[1].selectionPoint_l_2d_m, 
                                             'color':'yellow', 'unstretched_width_m':0.10, 'length_m':1.0, 'damper_Ns2pm2':0.15, 'strength_Npm':2.0});
                  messages['help'].newMessage('Name of the new spring is ' + tempSpring.name, 2.0);
                  
               } else {
                  messages['help'].newMessage('There is already a spring (' + sameLocalPointsWarning + ') on the selected points.', 2.0);
               }
            
            } else if (cP.Spring.nameForPasting in aT.springMap) {
               
               // Paste a copy of the source spring onto these two selected pucks (or pins).
               if (sameLocalPointsWarning == "") {
                  var newSpringName = aT.springMap[ cP.Spring.nameForPasting].copyThisOne( p[0], p[1], "pasteSingle");
                  
                  // If one of these is a NPC puck and the other a NPC navigation pin, supply the puck attributes needed for navigation.
                  if ((p[0].clientName) && (p[0].constructor.name == 'Puck') && (p[0].clientName.slice(0,3) == 'NPC') && (p[1].NPC)) {
                     p[0].navSpringName = newSpringName;
                     p[0].pinName = p[1].name;
                  } else if ((p[1].clientName) && (p[1].constructor.name == 'Puck') && (p[1].clientName.slice(0,3) == 'NPC') && (p[0].NPC)) {
                     p[1].navSpringName = newSpringName;
                     p[1].pinName = p[0].name;
                  }
                  
                  messages['help'].newMessage( newSpringName+' copied from '+cP.Spring.nameForPasting, 2.0);
                  
                  // De-select the pasted spring (and other selected springs) and its pucks (so the user doesn't have to click on empty space).
                  cP.Spring.findAll_InMultiSelect( spring => spring.selected = false);
                  hostMSelect.resetAll();
                  
               } else {
                   messages['help'].newMessage('Pasting onto the same points as another spring is not allowed. \\You may want to try turning off the COM option.', 3.5);
               }
               
            } else {
               messages['help'].newMessage('No spring was selected for copying (maybe deleted).', 2.0);
               cP.Spring.nameForPasting = null;
            }
            
         } else if ((hostMSelect.count() != 2)) {
            messages['help'].newMessage("Need 2 pucks/pins to paste a spring; "+hostMSelect.count()+" selected", 2.0);
         }      
      }
      
      addRevoluteJoint = function() {
         if (hostMSelect.count() == 2) {
            // p (a usefully short name) is an array of pucks.
            var p = [];
            
            hostMSelect.applyToAll( msObject => {
               // Populate the p array so you can pass the pucks as parameters.
               p.push( msObject);
            });
            /*
            // If the joint object has a high aspect ratio, help out, center the point in the thinnest dimension.
            function getAttachmentPoint( jointObject) {  
               let aspectRatioWH = jointObject.half_width_m / jointObject.half_height_m;
               let aspectRatioTarget = 2.0;
               let attachmentPoint_2d_m = jointObject.selectionPoint_l_2d_m.copy();
               if ( (jointObject.constructor.name == "Puck") && (jointObject.shape != 'circle') ) {
                  if (aspectRatioWH > aspectRatioTarget) {
                     // center along the local y direction
                     attachmentPoint_2d_m.y = 0.0;
                  } else if ((aspectRatioWH < 1.0/aspectRatioTarget)) {
                     // center along the local x direction
                     attachmentPoint_2d_m.x = 0.0;
                  } 
               }
               return attachmentPoint_2d_m;
            }
            let tempJoint = new cP.Joint(p[0], p[1], {'jto1_ap_l_2d_m': getAttachmentPoint( p[0]), 'jto2_ap_l_2d_m': getAttachmentPoint( p[1]), 'color':'darkred'} );
            */
            let tempJoint = new cP.Joint(p[0], p[1], {'jto1_ap_l_2d_m': p[0].selectionPoint_l_2d_m, 'jto2_ap_l_2d_m': p[1].selectionPoint_l_2d_m, 'color':'darkred'} );
            messages['help'].newMessage('Name of the new joint is ' + tempJoint.name, 2.0);
            
            cP.Spring.findAll_InMultiSelect( spring => spring.selected = false);
            hostMSelect.resetAll();
            
         } else {
            messages['help'].newMessage('Select two attachment points using multi-select features.', 3.0);
         }
      }
      
      
      document.addEventListener("keyup", function(e) {
         if (e.keyCode in keyMap) {
            // Set the key state to be UP.
            clients['local'][keyMap[e.keyCode]] = 'U';               
         }
         
         // Some specific actions.
         
         // Done with box-based selection.
         if (keyMap[e.keyCode] == 'key_alt') {
            hostSelectBox.stop();
            // This detach is needed for cases when ctrl-alt is used for multi-body rotations. This suppresses
            // the fling of the selected body that would result when the alt key is lifted. The "if" condition
            // keeps the dettach operation from firing in general alt key usage, for example when the alt-p
            // is used to inhibit screen erasing each frame. Wow, that's a lot of explaining for one line of code.
            if (clients['local'].key_ctrl == 'D') clients['local'].modifyCursorSpring('dettach');
            
         } else if (keyMap[e.keyCode] == 'key_ctrl') {
            // Detach the cursor spring. This prevents unintended movement when releasing the control key.
            //clients['local'].modifyCursorSpring('dettach');
            
            key_ctrl_handler('keyup', 'local');
            
         } else if (keyMap[e.keyCode] == 'key_shift') {
            // Done with the rotation action. Get ready for the next one.
            hostMSelect.resetCenter();
            clients['local'].modifyCursorSpring('dettach');
         }
         
      }, {capture: false}); //This "false" makes this fire in the bubbling phase (not capturing phase).
      
      
      // Gravity toggle
      dC.gravity = document.getElementById('chkGravity');
      function gravityToggle(e) {
         if (dC.gravity.checked) {
            c.g_ON = true;
         } else {
            c.g_ON = false;
         }
         setGravityRelatedParameters({'showMessage':true});
      }
      dC.gravity.addEventListener("click", gravityToggle, {capture: false});
      
      // COM (Center of Mass) selection toggle
      dC.comSelection = document.getElementById('chkCOM_Selection');
      comSelection_Toggle = function(e, duration_s = 1.0) {
         if (dC.comSelection.checked) {
            // Change the attachment point of the cursor springs to be at the center of the selected body.
            cP.Client.applyToAll( client => {if (client.selectedBody) client.cursorSpring.spo2_ap_l_2d_m = new cP.Vec2D(0,0)});
            messages['help'].newMessage('Center of mass (COM) selection: [base,yellow]ON[base]', duration_s);
         } else {
            // Change back to the actual selection points.
            cP.Client.applyToAll( client => {if (client.selectedBody) client.cursorSpring.spo2_ap_l_2d_m = client.selectionPoint_l_2d_m});
            messages['help'].newMessage('Center of mass (COM) selection: [base,yellow]OFF[base]', duration_s);
         }
      }
      dC.comSelection.addEventListener("click", comSelection_Toggle, {capture: false});
      
      // Multi-player toggle
      dC.multiplayer = document.getElementById('chkMultiplayer');
      dC.multiplayer.addEventListener("click", toggleMultiplayerStuff, {capture: false});
      
      // Stream choke
      dC.stream = document.getElementById('chkStream');
      dC.stream.addEventListener("click", toggleStream, {capture: false});
      function toggleStream() {
         // Turn the stream On/Off.
         if (dC.stream.checked) {
            hC.setCanvasStream('on');
         } else {
            hC.setCanvasStream('off');
         }
      }
      
      // Player option
      dC.player = document.getElementById('chkPlayer');
      dC.player.addEventListener("click", toggleLocalPlayer, {capture: false});
      function toggleLocalPlayer() {
         if (dC.player.checked) {
            clients['local'].player = true;
         } else {
            clients['local'].player = false;
         }
      }
      
      // Friendly-fire option
      dC.friendlyFire = document.getElementById('chkFriendlyFire');
      
      // Editor checkbox
      dC.editor = document.getElementById('chkEditor');
      function toggleEditorStuff() {
         if (dC.editor.checked) {
            messages['help'].newMessage('Wall and Pin editing: [base,yellow]ON[base]', 1.0);
         } else {
            messages['help'].newMessage('Wall and Pin editing: [base,yellow]OFF[base]', 1.0);
         }
      }
      dC.editor.addEventListener("click", toggleEditorStuff, {capture: false});
            
      // Pause toggle
      dC.pause = document.getElementById('chkPause');
      dC.pause.addEventListener("click", setPauseState, {capture: false});
      
      // Local cursor toggle
      dC.localCursor = document.getElementById('chkLocalCursor');
      dC.localCursor.checked = false;
      dC.localCursor.addEventListener("click", function() {
         if (dC.localCursor.checked) {
            canvas.style.cursor = 'default';
            // user must put this string in the chat field before checking the local-cursor box.
            if (dC.inputField.value == "lagtest") c.lagTesting = true;
         } else {
            canvas.style.cursor = 'none';
            dC.inputField.value = "";
            c.lagTesting = false;
         }
      }, {capture: false});   
      
      // NickName field in pool-game help
      $("#nickNameField").on("keyup", function(e) {
         if (e.key == "Enter") {
            $('#nickNameField').blur();
            if (c.demoVersion.slice(0,3) == "3.d") {
               demoStart(3);                 
            } else {
               demoStart_fromCapture(3, {'fileName':'demo3d.js'})
            }
            hC.changeFullScreenMode( canvas, 'on');
         }
      });
      
      // Fullscreen button (on host)
      dC.fullScreen = document.getElementById('btnFullScreen');
      dC.fullScreen.addEventListener("click", function() {
         hC.changeFullScreenMode( canvas, 'on');
      }, {capture: false});
      
      // fullscreen links (on host): any link with a class of fullScreenLink
      document.querySelectorAll('.fullScreenLink').forEach( item => {
         item.addEventListener('click', event => {
            setNickNameWithoutConnecting();
            hC.changeFullScreenMode( canvas, 'on');
            
         }, {capture: false});
      });
      
      // Fullcanvas button (on host)
      dC.fullCanvas = document.getElementById('btnFullCanvas');
      dC.fullCanvas.addEventListener("click", function() {
         // A longer delay is needed with FireFox.
         var userAgent = window.navigator.userAgent;
         if (userAgent.includes("Firefox")) {
            var waitForFullScreen = 600;
            console.log("firefox detected");
         } else {
            var waitForFullScreen = 100;
         }
         
         hC.changeFullScreenMode( canvas, 'on', waitForFullScreen + 700);
         
         window.setTimeout(function() {
            /*            
            Note that the 5-pixel edge here seems to resolve a system crashing 
            problem on my Intel NUC. The crash happens when exiting fullscreen mode 
            with the esc key when using a 0 or 1 pixel edge. The pattern is 
            full-canvas mode, then esc, then full-screen mode, then esc (crash). 
            */
            var edge_px = 5;           
            /*
            If one or both axes of the original canvas is larger than the window, 
            stretch the axis that has the lowest fractional value (relative to its 
            corresponding window axis). Stretch it in a way that the aspect ratio of 
            the stretched canvas is equal to the aspect ratio of the window. 
            That should yield a canvas that fills the window without cutting 
            off any territory or objects in the original canvas. 
            */
            var subwindow_px_w = window.innerWidth - edge_px;
            var subwindow_px_h = window.innerHeight - edge_px;
            
            var width_ratio = canvas.width / window.innerWidth;
            var height_ratio = canvas.height / window.innerHeight;
            
            if ((canvas.width > subwindow_px_w) || (canvas.height > subwindow_px_h)) {
               if (width_ratio < height_ratio) {
                  canvas.width = canvas.height * (window.innerWidth / window.innerHeight);
               } else {
                  canvas.height = canvas.width * (window.innerHeight / window.innerWidth);
               }
            } else {
               canvas.width  = subwindow_px_w;
               canvas.height = subwindow_px_h;               
            }
                    
            // This apparently needs to be reset after the canvas dimensional changes above.
            // (only needed for the color mixing demo #9)
            if (c.demoIndex == 9) ctx.globalCompositeOperation = 'screen';
            
            // If there is a fence, take it down and put up a new one running along the edge of the canvas.
            // (leave the fence as-is when playing pool) 
            if ( cP.Wall.checkForFence() && (c.demoVersion.slice(0,3) != "3.d") ) {
               if ( ['1.c','1.d','1.e'].includes( demoVersionBase( c.demoVersion)) ) {
                  cP.Wall.deleteFence();
                  // Have only a top wall for the piCalcEngine demos.
                  cP.Wall.makeFence({'bOn':false, 'rOn':false, 'lOn':false}, canvas); 
               } else {
                  let currentFenceParameters = cP.Wall.getFenceParms();
                  //console.log("fenceParms=" + JSON.stringify( currentFenceParameters) );
                  cP.Wall.deleteFence();
                  cP.Wall.makeFence( currentFenceParameters, canvas);
               }
            }
            
            // Let the clients know that the canvas dimensions have changed.
            setClientCanvasToMatchHost();
            // Capture the new layout so the demo can be restarted without having to run this again.
            saveState();
         }, waitForFullScreen); // delay needed for Firefox
         
      }, {capture: false});
      
      // For handling full-screen mode changes
      $(document).on('webkitfullscreenchange mozfullscreenchange fullscreenchange msfullscreenchange', function(e) {
         // Check the state:
         // Starting fullscreen
         if (document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) {
            console.log('fullscreen state: TRUE');
            c.fullScreenState = true;
            canvas.style.borderWidth = '0px';
            canvas.style.backgroundColor = '#000000'; // black for viewing fullscreen
         
         // Exiting fullscreen
         } else {
            restartAnimationLoop( 500);
            console.log('fullscreen state: FALSE');
            c.fullScreenState = false;
            canvas.style.borderWidth = '5px';
            canvas.style.backgroundColor = c.borderAndBackGroundColor; // Must match border color to avoid border edge problem in Chrome. (border is usually '#008080')
         }
      });
      
      // The running average.
      aT.dt_RA_ms = new cP.RunningAverage(60);
      dC.fps = document.getElementById("fps");
      
      dC.extraDemos = document.getElementById("extraDemos");
      dC.indexInPlusRow = document.getElementById("indexInPlusRow");
      
      // Add a local user to the clients dictionary.
      new cP.Client({'name':'local', 'color':'tomato'});
    
      // Start the blank demo for frame rate testing.
      demoStart( 0, {'restartLoop':false});
      var fpsTestDelay = 1800;
      var startupDelay =  2000;
      
      // Wait about 2 seconds for the blank demo (#0) to settle in, then set the physics time-step (frame rate) 
      // based on the observed display rate.
      messages['help'].newMessage('starting...', startupDelay/1000.0);
      window.setTimeout( function() { 
         setFrameRateBasedOnDisplayRate();
      }, fpsTestDelay);
      
      window.setTimeout( function() { 
         // Start the "ready" message about 0.5 seconds before the demo starts.
         messages['help'].newMessage('...ready.', 0.8);
      }, startupDelay - 500);
      
      //////////////////////////////////////////////////////////////////////////
      // Now, about 0.2 seconds after the framerate measurement, start the demo.
      //////////////////////////////////////////////////////////////////////////
      window.setTimeout( function() {
         if (demoFromURL.file) {
            demoStart_fromCapture( demoFromURL.index, {'fileName':demoFromURL.file});
         } else if (( ! demoFromURL.file) && demoFromURL.index) {
            demoStart( demoFromURL.index);
         } else {
            // don't scroll the demo help when the page loads
            demoStart( 9, {'scrollHelp':false});
            if (scrollTargetAtStart) scrollDemoHelp( scrollTargetAtStart); 
         }
      }, startupDelay);
      
   } // end of init()

   
   // It's alive. MuuuUUuuuAhhhh Haaaaaa Ha Ha Ha.
   function gameLoop( timeStamp_ms) {
      // Note: The time-stamp argument can have any name.
      
      dt_frame_ms = timeStamp_ms - time_previous;
      //dt_frame_ms = c.deltaT_s * 1000;
      //dt_frame_ms = 1000 * 1/60.0
      dt_frame_s = dt_frame_ms / 1000.0;
      
      if (resumingAfterPause || (dt_frame_s > 0.1)) {
         // Use the dt info saved in last frame before it was paused.
         dt_frame_ms = dt_frame_previous_ms;
         dt_frame_s = dt_frame_ms / 1000.0;
         time_previous = performance.now();
         resumingAfterPause = false;
      }
      
      if (c.dtFloating) c.deltaT_s = dt_frame_s;
      
      var dt_avg_ms = aT.dt_RA_ms.update( dt_frame_ms);
      
      // Report frame-rate every half second.
      if (aT.dt_RA_ms.totalSinceReport > 500.0) {
         dC.fps.innerHTML = (1/(dt_avg_ms/1000)).toFixed(0);
         aT.dt_RA_ms.totalSinceReport = 0.0;
      }
      
      // Draw the walls, step the engine, draw the pucks.
      updateAirTable();
      
      time_previous = timeStamp_ms;
      dt_frame_previous_ms = dt_frame_ms
      
      myRequest = window.requestAnimationFrame( gameLoop);
      if (c.singleStep) stopit();
   }
   
   function clearCanvas() {
      let canvas_width_px_int = Math.round( canvas.width);
      let canvas_height_px_int = Math.round( canvas.height);
      
      // Clear the canvas (from one corner to the other)
      if (ctx.globalCompositeOperation == 'screen') {
         ctx.clearRect(0,0, canvas_width_px_int, canvas_height_px_int);
         
         ctx.fillStyle = 'black';
         ctx.fillRect(0,0, canvas_width_px_int, canvas_height_px_int);
         
      } else {
         ctx.fillStyle = c.canvasColor;
         ctx.fillRect(0,0, canvas_width_px_int, canvas_height_px_int);
      }
   }
   
   function updateAirTable() {
      /*
      This update function is structured as follows:
      
      1. Based on user input (cursor, keyboard, and network client), update all things that affect the physics engine:
         position the cursor springs and the ghost-puck sensor, deleted objects, and calculate external spring and impulse forces.
      2. Step the physics engine.
      3. Calculate the screen positions of objects as affected by the physics engine
         and draw the results.
      */
      
      /*
      Most of the event-based (asynchronous) input is "allowed" to enter this 
      update function at any time. However, the mouse position input is copied 
      at the beginning of this function. For consistency, all processing of 
      the mouse position is based on this copy, not the mouse_async_2d_px 
      values. In effect, the async mouse input is blocked from entering this 
      function anywhere but here at the beginning. 
      
      I put quotes on "allowed" in the previous paragraph because the 
      mousemove events are coalesced (by the browser) and released at the 
      beginning of requestAnimationFrame cycle. So you will NEVER see host's 
      (local client) mouse position updated during the execution of this 
      updateAirTable function. Network clients mouse position can be updated 
      during updateAirTable since the WebRTC events are not coalesced. So the 
      use of the "copy" actually enforces consistency for the clients mouse 
      position during updateAirTable. 

      Search this code for the c.lagTesting flag (only changeable via code edit) 
      that enables the drawing of a little circle directly in the event handlers 
      for mouse position (), cyan for host, white for network clients.
         * Use alt-p to inhibit erasing
         * Use p to inhibit the requestAnimationFrame loop.
      
      Note: web search on getCoalescedEvents.
      
      Input delay (or lag) is an issue with html games and to some extent 
      this site. If you check the "Multiplayer" option and then check the 
      "Local cursor" option you will be able see the delay to the cursor 
      rendered on the canvas. This delay will generally range from 2 to 5 
      fames. You can calibrate your sense of delay here: 
      
      https://www.vsynctester.com/
      */
      
      // Copy the event-based results for use in the loop.
      cP.Client.applyToAll( client => {
         client.prev_mouse_2d_px = client.mouse_2d_px.copy();
         client.mouse_2d_px = client.mouse_async_2d_px.copy();
         client.mouse_2d_m = worldFromScreen( client.mouse_2d_px);
      });
      
      /*
      Leaving this commented block here as an example of a technique for deleting elements
      from an array when looping over it.
      
      // Clean out old bullets and unhealthy pucks. Note this loops
      // in reverse order over the array to avoid indexing problems as the
      // array elements are deleted.
      for (var j = aT.pucks.length - 1; j >= 0; j--) {
         if (aT.pucks[j].bullet) {
            var age_ms = window.performance.now() - aT.pucks[j].createTime;
            if (age_ms > aT.pucks[j].ageLimit_ms) {  
               deletePuckAndParts( aT.pucks[j]);
               aT.pucks.splice(j, 1);
            }
         } else if (aT.pucks[j].poorHealthFraction >= 1.0) {
            deletePuckAndParts( aT.pucks[j]);
            aT.pucks.splice(j, 1);
         }
      }     
      */
      
      // Clean out old bullets and unhealthy pucks.
      if (c.demoIndex == 7 || c.demoIndex == 8) {
         cP.Puck.applyToAll( puck => {
            if (puck.bullet) {
               //var age_ms = window.performance.now() - puck.createTime;
               puck.age_ms += c.deltaT_s * 1000;
               if (puck.age_ms > puck.ageLimit_ms) { 
                  // First penalize the shooter if no hits by this bullet.
                  if ((!puck.atLeastOneHit) && (!cP.Client.winnerBonusGiven)) {
                     // Make sure the client is still there...
                     if (clients[ puck.clientNameOfShooter]) {
                        // Now the penalty.
                        clients[ puck.clientNameOfShooter].score -= 1;
                     }
                  }
                  // Then remove it.
                  puck.deleteThisOne({});
               }
            } else if (puck.poorHealthFraction >= 1.0) {
               puck.deleteThisOne({});
            }
         });  
      }
      
      if (aT.collisionInThisStep) {
         // If not using the PiEngine but still doing some pi calculations (e.g. demo 1c), 
         // you'll need to do a few things like play the clack sound.
         if ( ! c.piCalcs.usePiEngine) {
            if (c.piCalcs.clacks) sounds['clack2'].play();
            if (c.piCalcs.enabled) {
               aT.puckMap['puck1'].vmax = Math.max( aT.puckMap['puck1'].vmax, aT.puckMap['puck1'].velocity_2d_mps.y);
               messages['help'].newMessage("count = " + aT.collisionCount + "\\v max = " + aT.puckMap['puck1'].vmax.toFixed(1));
            }
         } 
      }
      
      cP.Spring.applyToAll( spring => {
         // If either puck/pin has been deleted, remove the spring.
         if (spring.spo1.deleted || spring.spo2.deleted) {
            // Remove this spring from the spring map.
            spring.deleteThisOne({});
         } else {
            // Otherwise, business as usual.
            spring.force_on_pucks();
         }
      });
      cP.Joint.applyToAll( joint => {
         // If either table object has been deleted, remove the joint.
         if (joint.jto1.deleted || joint.jto2.deleted) {
            joint.deleteThisOne({});
         }
      });
      
      // Update the games
      if (c.demoVersion.slice(0,3) == "3.d") {
         // A timer limits how often checks are run on pool-game state. Check once for every c.poolTimer_stateCheckLimit_s timer period.
         gB.checkPoolGameState( ctx);
      
      } else if ((c.demoIndex == 6) && (aT.jelloPucks.length > 0)) {
         checkForJelloTangle();
      
      } else if (c.demoIndex == 7 || c.demoIndex == 8) {
         pP.checkForPuckPopperWinnerAndReport();
      }

      // Consider all client-mouse influences on a selected object.
      cP.Client.applyToAll( client => {
         
         // Jets and Guns
         if (client.puck) {
            // Tell the NPCs what to do.
            if (client.name.slice(0,3) == 'NPC') {
               if ( ! pP.getNpcSleep()) pP.thinkForNPC( client, c.deltaT_s);
            }
            // Respond to client controls, calculate corresponding jet and gun recoil forces, and draw.
            client.puck.jet.update( c.deltaT_s);
            client.puck.gun.update( c.deltaT_s);
            
            // If sweeping the gun with the TwoThumbs scope control, send out the resulting gunAngle to the client.
            pP.gunAngleFromHost( client, c.deltaT_s);
         }
         
         // Check to see if the mouse button is down and if there's a body under the cursor.
         // Select it and/or add it to the multi-select group.
         client.checkForMouseSelection();
         
         // Note that network clients are NOT allowed to select walls and pins (see checkForMouseSelection).
         // So only the local client will get into the following block in those (wall and pin) cases.
         if (client.selectedBody) {
            // World position of selection points are needed for direct movements and for spring calculations.
            client.updateSelectionPoint();
            
            gB.resetPathAfterShot( client);
            
            // direct movement
            if ((client.key_ctrl == 'D') || (client.ctrlShiftLock)) {
               
               // If the choke is open (null), take exclusive ownership of direct movements. Note that this client
               // will release its ownership via mouse-up or control-key-up events.
               if (client.selectedBody.firstClientDirectMove == null) {
                  client.selectedBody.firstClientDirectMove = client.name;
               } 
               // Allow only one client at a time to make direct movements.
               if (client.name == client.selectedBody.firstClientDirectMove) {
                  // translation
                  if ((client.key_ctrl == 'D') && (client.key_shift == 'U') && (client.key_alt == 'U')) {
                     client.moveToCursorPosition();
                  // rotation
                  } else if ( ((client.key_ctrl == 'D') && (client.key_shift == 'D')) || ((client.ctrlShiftLock) && (client.selectedBody.constructor.name == 'Puck')) ) {
                     client.rotateToCursorPosition();
                  } else if ((client.key_ctrl == 'D') && (client.key_alt == 'D')) {
                     client.rotateEachAboutItself();
                  }               
               }
            }
         }
         
         // Prepare to draw a cursor for the local and network clients.
         if (client.name.slice(0,3) != 'NPC') {
            if ( (client.deviceType != 'mobile') && ( ! client.twoThumbsEnabled) ) { 
               client.updateCursor(); // and ghost ball...
            }
         } 
      });

      // Sum up all the forces and apply them to the pucks.
      cP.Puck.applyToAll( puck => {
         puck.applyForces( c.deltaT_s) 
      });
      
      //////////////////////////////////////////////////////////////////////////////////////////////////////
      // Step the physics engine (calculate the resulting state of the objects)
      //////////////////////////////////////////////////////////////////////////////////////////////////////
      if (c.piCalcs.usePiEngine) {
         piCalcEngine.step( c.deltaT_s);
      } else {
         aT.collisionInThisStep = false;
         world.Step( c.deltaT_s, 10, 10);  // dt, vel iterations, pos iterations: dt,10,10
         world.ClearForces();
      }
      ///////////////////////////////////////////////////////////////////////////////////////////////////////
      ///////////////////////////////////////////////////////////////////////////////////////////////////////
      
      // Precede all drawing operations by clearing off the canvas.
      if ( ! c.pauseErase) {
         clearCanvas();
      }
      
      // Start with the walls (render these on the bottom).
      cP.Wall.applyToAll( wall => {
         wall.draw( ctx);
      });
      
      if (c.demoVersion.slice(0,3) == "3.d") {
         cP.Client.applyToAll( client => {
            gB.drawPathAfterShot( ctx, client);
         });
      }
      
      cP.Puck.applyToAll( puck => {
         if ( ! c.piCalcs.usePiEngine) {
            puck.updateState();
         }
         puck.draw( ctx, c.deltaT_s);
      });
      
      // Select all springs where both ends are connected to pucks/pins in the multiselect map.
      cP.Spring.findAll_InMultiSelect( spring => spring.selected = true);
      cP.Joint.findAll_InMultiSelect( joint => joint.selected = true);
      
      cP.Spring.applyToAll( spring => {
         if ( (hostMSelect.count() == 2) && (spring.inMultiSelect()) ) spring.report();
         spring.draw( ctx);
      });
      
      cP.Pin.applyToAll( pin => {
         pin.draw( ctx, pin.radius_px);
      });
      
      cP.Joint.applyToAll( joint => {
         joint.draw( ctx);
      });
      
      // Draw a marking circle on each object in the multi-select map.
      if (hostMSelect.count() > 0) {
         hostMSelect.applyToAll( msObject => msObject.draw_MultiSelectPoint( ctx) );
      }
      
      cP.Client.applyToAll( client => {
         if (client.puck) {
            client.puck.jet.draw( ctx);
            client.puck.gun.draw( ctx, c.deltaT_s);
         }
         if (client.gBS.readyToDraw) {
            gB.drawGhostBall( ctx, client);
            client.gBS.readyToDraw = false;
         }
         if (client.selectedBody) {
            client.drawSelectionPoint( ctx);
         }
         if ( (client.name.slice(0,3) != 'NPC') && (client.deviceType != 'mobile') && ( ! client.twoThumbsEnabled) ) { 
            client.drawCursor( ctx);
         }
         if (c.drawSyncImage) {
            dF.drawLine( ctx, new cP.Vec2D(10,15), new cP.Vec2D(40,15), {'width_px':10, 'color':'white'} );
         }
         if ((client.sendDrawSyncCommand) && (client.name != 'local')) {
            let control_message = {'from':'host', 'to':client.name, 'data':{'drawSync':{'value':c.drawSyncImage}} };
            hC.sendSocketControlMessage( control_message);
            client.sendDrawSyncCommand = false; // so start/stop messages only gets sent once
         }
      });
      
      if ( (c.demoVersion.slice(0,3) == "3.d") || (c.demoIndex == 7) || (c.demoIndex == 8) ) {
         messages['score'].displayIt( c.deltaT_s, ctx);
         messages['ppTimer'].displayIt( c.deltaT_s, ctx);
      } else if ((c.demoVersion.slice(0,3) == "6.a") || (c.demoVersion.slice(0,3) == "6.d")) { 
         messages['jelloTimer'].displayIt( c.deltaT_s, ctx);
      }
      
      messages['help'].displayIt( c.deltaT_s, ctx);
      messages['help2'].displayIt( c.deltaT_s, ctx);
      messages['gameTitle'].displayIt( c.deltaT_s, ctx);
      messages['win'].displayIt( c.deltaT_s, ctx);
      
      messages['lowHelp'].loc_px.y = canvas.height - 50; // adjust this near-the-bottom help as needed
      messages['lowHelp'].displayIt( c.deltaT_s, ctx);
      
      if (messages['videoTitle']) messages['videoTitle'].displayIt( c.deltaT_s, ctx); // See demo #0
      
      // Display the selection box.
      if (hostSelectBox.enabled) {
         hostSelectBox.update();
         hostSelectBox.draw( ctx);
      }
      
   } // End of updateAirTable
      
             
   /*   
   Reveal public references.
   
   You can reveal mutable objects and functions. But javascript primitives 
   (always copied by values instead of reference) must be accessed with 
   corresponding get and set functions. So, any variable that points to a 
   primitive (and can be changed in this module, not a constant) must be 
   revealed through the use of get and set functions.
   
   An alternative, to the get and set functions for the attributes of c, is to 
   reveal the c object, which is mutable (more control with get and set).
   */

   return {
      // Objects
      b2d_getPolygonVertices_2d_px: b2d_getPolygonVertices_2d_px,
      b2d_getPolygonVertices_2d_m:  b2d_getPolygonVertices_2d_m,
      b2d_getBodyAt: b2d_getBodyAt,
      
      world: world,
      
      getCanvasDimensions: getCanvasDimensions,
      
      tableMap: tableMap,
      hostMSelect: hostMSelect,
      clients: clients,
      sounds: sounds,
      dC: dC,
      keyMap: keyMap,
      messages: messages,
      aT: aT,
      
      // Variables
      getG_ON: function() { return c.g_ON; },
      
      getG_mps2: function() { return c.g_mps2; },
      
      getPx_per_m: function() { return c.px_per_m; },
      
      getDeltaT_s: function() { return c.deltaT_s; },
      
      getSingleStep: function() { return c.singleStep; },
      
      getFrameRate: function() { return c.frameRate; },
      
      getChatLayoutState: function() { return c.chatLayoutState; },
      
      getDemoVersion: function() { return c.demoVersion; },
      
      getDemoIndex: function() { return c.demoIndex; },
      
      //getNpcSleep: function() { return c.npcSleep; },
      
      //getNpcSleepUsage: function() { return c.npcSleepUsage; },
      //setNpcSleepUsage: function( val) { c.npcSleepUsage = val },
      
      getPauseErase: function() { return c.pauseErase; },
      
      getLagTesting: function() { return c.lagTesting; },
      
      //setPuckPopperTimer_s: function( t_s) { c.puckPopperTimer_s = t_s },
      
      getSoftConstraints_default: function() { return c.softConstraints_default; },
      
      getLastClientToScoreHit: function() { return c.lastClientToScoreHit; },
      
      // Methods
      reportGameResults: reportGameResults,
      submitScoresThenReport: submitScoresThenReport,
      toggleMultiplayerStuff: toggleMultiplayerStuff,
      
      createNetworkClient: createNetworkClient,
      deleteNetworkClient: deleteNetworkClient,
      updateClientState: updateClientState,
      deleteRTC_onHost: deleteRTC_onHost,
      deleteRTC_onClientAndHost: deleteRTC_onClientAndHost,
      setClientCanvasToMatchHost: setClientCanvasToMatchHost,      
      adjustSizeOfChatDiv: adjustSizeOfChatDiv,
      
      init: init,
      startit: startit,
      stopit: stopit,
      setFrameRate: setFrameRate,
      tableActions: tableActions,
      freeze: freeze,
      stepAnimation: stepAnimation,
      restartAnimationLoop: restartAnimationLoop,
      stopRotation: stopRotation,
      reverseDirection: reverseDirection,
      toggleElementDisplay: toggleElementDisplay,
      toggleSpanValue: toggleSpanValue,
      key_ctrl_handler: key_ctrl_handler,
      scrollDemoHelp: scrollDemoHelp,
      openDemoHelp: openDemoHelp,
      fullScreenState: fullScreenState,
      demoStart: demoStart,
      
      saveState: saveState,
      clearState: clearState,
      clearTable: clearTable,
      cleanCapture: cleanCapture,
      restoreFromState: restoreFromState,
      demoStart_fromCapture: demoStart_fromCapture
   };
   
})();