/*
Copyright 2022 James D. Miller

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

// Game Window (gW) module
// gwModule.js 
   console.log('GW version 0.0');
// 4:27 PM Wed August 10, 2022

/*
Dependencies for gwModule.js:
   constructorsAndPrototypes.js (cP.)
   hostAndClient.js (hC.)
   captureRestore.js (cR.)
   leaderBoard.js (lB.)
   puckPopper.js (pP.)
   jellowMadness.js (jM.)
   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.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;
   
   // 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;
   c.frameCount = 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.lastClientToScoreHit = null;
   
   c.chatLayoutState = 'notSetYet';
   
   c.singleStep = false;
   c.softConstraints_default = false;
   
   c.canvasColor = 'black';
   
   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;
   
   c.displaySCM = false; // System Center of Mass for all pucks
   c.displayMSC = false; // Multi-Select Center
   
   // 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), // n copies...
      'highPop': new cP.SoundEffect("sounds/puckpop.mp3", 5),
      'clack2':  new cP.SoundEffect("sounds/clack_long_b.mp3", 35), //35 100 Note: version "b" avoids overly quiet play in chrome.
      
      'monkeyPlacement':  new cP.SoundEffect("sounds/monkey-mocking-and-giggling.mp3", 1, 0.10),
      'monkeyPlacement2': new cP.SoundEffect("sounds/monkey-mocking-laugh.mp3",        1, 0.10),
      'monkeyOK':         new cP.SoundEffect("sounds/monkey-baby-laugh.mp3",           1, 0.10),
      'monkeyAlarmed':    new cP.SoundEffect("sounds/monkey-alarmed.mp3",              1, 0.10)
   };
   
   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 (called after page load), can be exported.
   var key_b_handler, key_c_handler, key_n_handler, key_l_handler, clickToClearMulti, clearMultiSelect, handleMouseOrTouchMove, mouseUp_handler, 
       wheelEvent_handler, comSelection_Toggle, pasteSpring, addRevoluteJoint, addSpringyChain;
   
   // 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',
                 
                 '8':'key_backspace', '9':'key_tab', '13':'key_enter', '16':'key_shift', '17':'key_ctrl', 
                 '18':'key_alt', // both left and right alt key on Windows
                 '32':'key_space', 
                 
                 // 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";
   
   // 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.slice(0,14) == "5.e.basketball") {
            bpH.processBasketBallCollisions( body_Wall, body_Puck);
         
         // Monkey Hunt
         } else if (c.demoVersion.slice(0,3) == "4.e") {
            mH.processWallCollisions( body_Puck, body_Wall);
         }
                  
      } else if ((body_A.constructor.name == "Puck") && (body_B.constructor.name == "Puck")) {
         // Handle the case where one puck is a gun bullet and one is not.
         if ((body_A.gunBullet() && !body_B.gunBullet()) || (body_B.gunBullet() && !body_A.gunBullet())) {
            
            if (body_A.gunBullet()) {
               var bullet = body_A, target = body_B;
            } else {
               var bullet = body_B, target = body_A;
            }
            
            if ([7,8].includes( c.demoIndex)) {
               // 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 the shield is off or weakened...
                  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;
                     }
                  }
               }
            }
            
         // Monkey Hunt
         } else if (c.demoVersion.slice(0,3) == "4.e") {
            mH.processHits( body_A, body_B);
            
         // 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;
      }
   }
   
   
   // Client related....
   
   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} });
   }
   
   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")) {
            if (c.demoVersion.slice(0,3) == "3.d") {
               // If some version of the pool game is running, restart that version.
               demoStart(3);
            } else {
               // If something other than a pool game is running, load in 9-ball.
               cR.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);
         */
      }
   }
   
   
   // 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 selected_b2d_Body = null;
      var tableBodyMap = {};
      var tableBodyNames = [];
      
      world.QueryAABB( function( fixture) {
         let b2d_Body = fixture.GetBody();
         let tableBody = tableMap.get( b2d_Body);
         let ghostSensor = (b2d_Body.GetUserData() == "ghost-sensor");
         
         // Don't consider cursor pins or ghost sensors (i.e. don't let a client select 
         // another client's cursor pin or a pool-shot ghost sensor). 
         if (( ! tableBody.cursorPin) && ( ! ghostSensor)) {
            // Take fixtures where this mouse position can be found locally on it. This is
            // final confirmation that yes, this fixture is under the mouse.
            if (fixture.GetShape().TestPoint( b2d_Body.GetTransform(), mousePVec_2d_m)) {
               // update the single-body reference
               selected_b2d_Body = b2d_Body;
               // update the body map
               tableBodyMap[ tableBody.name] = tableBody;
            }
         }
         // return true to continue checking the rest of the fixtures returned by the query
         return true;
      }, aabb);
      
      // If found at least one object there...
      if (selected_b2d_Body) {
         tableBodyNames = Object.keys( tableBodyMap);
         //console.log("names=" + JSON.stringify( tableBodyNames));
         
         // For overlapping non-colliding objects, find the highest numbered pin, puck, and wall.
         // This allows you to cursor select the object that is drawn on top.
         if (tableBodyNames.length > 1) {
            let maxPinNumber = 0;
            let maxPuckNumber = 0;
            let maxWallNumber = 0;
            
            for (let bodyName in tableBodyMap) {
               let tableBody = tableBodyMap[ bodyName];
               
               if (tableBody.constructor.name == "Pin") {
                  let pinNumber = Number( bodyName.slice(3));
                  if (pinNumber > maxPinNumber) maxPinNumber = pinNumber;
                  
               } else if (tableBody.constructor.name == "Puck") {
                  let puckNumber = Number( bodyName.slice(4));
                  if (puckNumber > maxPuckNumber) maxPuckNumber = puckNumber;
                  
               } else if (tableBody.constructor.name == "Wall") {
                  let wallNumber = Number( bodyName.slice(4));
                  if (wallNumber > maxWallNumber) maxWallNumber = wallNumber;
               }
            }
            
            // Pucks are drawn after (on top of) walls. Pins are drawn after pucks.
            // Within a type, the highest number is drawn last.
            // So, give selection priority to pins, then pucks, and finally walls. 
            // Update the single-body reference to point to the highest number.
            if (maxPinNumber > 0) {
               selected_b2d_Body = tableBodyMap["pin" + maxPinNumber].b2d;
            } else if (maxPuckNumber > 0) {
               selected_b2d_Body = tableBodyMap["puck" + maxPuckNumber].b2d;
            } else if (maxWallNumber > 0) {
               selected_b2d_Body = tableBodyMap["wall" + maxWallNumber].b2d;
            }
         }
         return selected_b2d_Body; 
         
      } 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);
   }
      
   function key_b_handler( 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') {
            exitFineMoves( client.name);
            messages['lowHelp'].newMessage("[base,lightgray]high-res positioning ("+ nameForHelp +"): [base,yellow]OFF[base]", 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 agrees 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");
         // This hides the border glitch in Chrome...
         if ( ! c.fullScreenState) canvas.style.backgroundColor = c.borderAndBackGroundColor;
      }
   }
   
   function oneFrameIfPaused( delay_ms = 0) {
      /*
      Force a single-frame update. This is useful for avoiding a black screen if paused and changing to full-canvas.
      Note: this must be delayed until after the pause and restart in restartAnimationLoop.
      (see comments in dC.fullCanvas.addEventListener)
      */
      window.setTimeout( function() {
         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);
            // Do one step so that this message gets written to the canvas.
            stepAnimation();
         }
      }, delay_ms);
   }

   function restartAnimationLoop( delay_ms) {
      // This pause and restart, helps to minimize the lag in the browser's mouse input. Note you can visualize the lag by putting the string "lagtest" in the chat
      // and then checking the local cursor option under multiplayer. This displays a circle with radius equal to the cursor movement in two frames.
      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;
      
      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);
      } 
      
      c.g_ON = true;
      dC.gravity.checked = c.g_ON;
      setGravityRelatedParameters({"updatePucks":false});
   }
   
   addSpringyChain = function( placement_2d_m, pars = {}) {
      let n_pucks = setDefault( pars.n_pucks, 30);
      let groupIndex = setDefault( pars.groupIndex, 0);
      let useSprings = setDefault( pars.useSprings, true);
      let puckColor = setDefault( pars.puckColor, 'brown');
      let puckBorder_px = setDefault( pars.puckBorder_px, 0);
      let jointVisibility = setDefault( pars.jointVisibility, false);
      
      let firstPuck, puck_A, puck_B, joint_AB, spring_AB;
      
      let half_width_m = 0.20;
      let jointFromEnd_m = 0.0;
      let puckParameters = {'shape':'rect', 'half_height_m':0.05, 'half_width_m':half_width_m, 'color':puckColor, 'borderColor':'gray', 'borderWidth_px':puckBorder_px, 
                            'angle_r': Math.PI/2, 'groupIndex':groupIndex, 'restitution':0, 'restitution_fixed':true};
      let velocity_2d_m = new cP.Vec2D(0.0,0.0);
      
      let newPuckPosition_2d_m = placement_2d_m.copy();
      
      let angle_delta_r = (2 * Math.PI)/n_pucks;
      let angle_delta_deg = 360/n_pucks;
      
      // spoke length is the distance from the center of the group out to the center of one rectangular puck.
      // tan( angle_delta_r / 2) = opp/adj = half_width_m / spoke_length
      let spokeLength_m = half_width_m / Math.tan( angle_delta_r / 2);
      let spoke_2d_m = new cP.Vec2D( spokeLength_m, 0.0);
      
      let attachSpring = function( puck_1, puck_2) {
         let localPoint_1 = new cP.Vec2D( -half_width_m + jointFromEnd_m, 0.0);
         let localPoint_2 = new cP.Vec2D( +half_width_m - jointFromEnd_m, 0.0);
         let length_m = ((half_width_m - jointFromEnd_m) * 4)  +  1 * half_width_m;
         spring_AB = new cP.Spring( puck_1, puck_2, {'spo1_ap_l_2d_m': localPoint_1 , 'spo2_ap_l_2d_m': localPoint_2,
                                                     'color':'gray', 'unstretched_width_m':0.01, 'length_m':length_m, 'damper_Ns2pm2':0.30, 'strength_Npm':10.0, 'fixedLength':false,
                                                     'visible':false});
      }
      
      for (let i = 1; i <= n_pucks; i++) {
         newPuckPosition_2d_m = placement_2d_m.add( spoke_2d_m);
         puck_B = new cP.Puck( newPuckPosition_2d_m, velocity_2d_m, puckParameters);
         
         if (i==1) {
            firstPuck = puck_B;
            
         } else {
            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),
                                                      'visible':jointVisibility}); 
            if (useSprings) attachSpring( puck_A, puck_B);
         } 
         
         puckParameters.angle_r += angle_delta_r;
         spoke_2d_m.rotated_by( angle_delta_deg);
         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), 'visible':jointVisibility});
      if (useSprings) attachSpring( puck_B, firstPuck);
   }
   
   function tableActions( callAction = null) {
      let tableAction, placement_2d_m, placementByMouse;
      
      if (callAction) {
         tableAction = callAction;
      } else {
         // use the selected value in the remove/add menu.
         tableAction = $('#TableActions').val();
      }
      
      // 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 (tableAction == "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 (tableAction == "puck-rect-bullet") {
         if ([7,8].includes( c.demoIndex)) {
            messages['help'].newMessage( "Please choose a regular (non-bullet) puck for demos 7 and 8.", 2.0);
         } else {
            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 (tableAction == "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 (tableAction == "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 (tableAction == "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 (tableAction == "wall") {
         new cP.Wall( placement_2d_m, {'half_width_m':0.6, 'half_height_m':0.02});
         
      } else if (tableAction == "pin") {
         new cP.Pin( placement_2d_m, {'fillColor':'lightBlue'});
         
      } else if (tableAction == "puck-circle") {
         new cP.Puck( placement_2d_m, new cP.Vec2D(0.0,0.0), {'radius_m':0.2});
         
      } else if (tableAction == "puck-circle-bullet") {
         if ([7,8].includes( c.demoIndex)) {
            messages['help'].newMessage( "Please choose a regular (non-bullet) puck for demos 7 and 8.", 2.0);
         } else {
            new cP.Puck( placement_2d_m, new cP.Vec2D(0.0,0.0), {'radius_m':0.2, 'bullet':true});
         }
         
      } else if (tableAction == "puck-circular-tail") {
         new cP.Puck( placement_2d_m, new cP.Vec2D(0.0,0.0), {'radius_m':0.2, 'createTail':true, 'tail':{'propSpeed_ppf_px':2, 'length_limit':35} });
         
      } else if (tableAction == "spring") {
         // paste default spring...
         pasteSpring(true);
      
      } else if (tableAction == "fixed-length-spring") {
         // default spring with fixed length (distance joint)
         pasteSpring(true, {'fixedLength':true});
         
      } else if (tableAction == "toggle-spring-visibility") {
         cP.Spring.findAll_InMultiSelect( spring => {
            spring.visible = ! spring.visible;
         });  
         
      } else if (tableAction == "clear-table") {
         clearTable("all");
         
      } else if (tableAction == "clear-all-but-walls") {
         clearTable("all-but-walls");
         
      } else if (tableAction == "clear-all-but-fence") {
         clearTable("all-but-fence");
         
      } else if (tableAction == "clear-fence") {
         cP.Wall.deleteFence();
         
      } else if (tableAction == "add-fence") {
         cP.Wall.makeFence({}, canvas);
         
      } else if (tableAction == "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...
            jM.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...
            jM.makeJello({'gridsize':4, 'addToJello':addToJello, 'offset_2d_m':placement_2d_m, 'restitution':0});
         }
       
      } else if (tableAction == "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 (tableAction == "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);
            
         } else {
            messages['help'].newMessage('Add drones in 7, 8, or captures of them.', 2.0);
         } 
                                                                  
      } else if (tableAction == "add-revolute") { 
         addRevoluteJoint();
         
      } else if (tableAction == "add-revolute-limits") { 
         if (hostMSelect.count() >= 2) {
            let noMatchCount = 0;
            cP.Joint.findAll_InMultiSelect( joint => {
               let j_angle_d = (joint.b2d.GetJointAngle() * 180.0/Math.PI);
               let angleFromStraight = 20;
               if (Math.abs( j_angle_d) < 70) {
                  joint.setLimits( -angleFromStraight, angleFromStraight);
                  joint.setEnableLimit( true);
               } else if ((j_angle_d > 360-70) && (j_angle_d < 360+70)) {
                  joint.setLimits( (360-angleFromStraight), (360+angleFromStraight));
                  joint.setEnableLimit( true);
               } else if ((j_angle_d > -360-70) && (j_angle_d < -360+70)) {
                  joint.setLimits( (-360-angleFromStraight), (-360+angleFromStraight));
                  joint.setEnableLimit( true);
               } else {
                  noMatchCount++;
                  joint.setEnableLimit( false);
               }
            });
            console.log("no match = " + noMatchCount);
         } else {
            messages['help'].newMessage('Select at least two bodies using multi-select features.', 3.0);
         }
         
      } else if (tableAction == "toggle-revolute-limits") { 
         if (hostMSelect.count() >= 2) {
            cP.Joint.findAll_InMultiSelect( joint => { 
               if ((joint.lowerLimit_deg) && (joint.upperLimit_deg)) {
                  joint.setEnableLimit( ! joint.enableLimit);
               }
            });
         } else {
            messages['help'].newMessage('Select at least two bodies using multi-select features.', 3.0);
         }
         
      } else if (tableAction == "add-chain") { 
         addSpringyChain( placement_2d_m, {'n_pucks':20, 'groupIndex':0, 'useSprings':false, 'puckColor':'DarkSlateGray', 'puckBorder_px':2, 'jointVisibility':true});
      
      } else if (tableAction == "add-springy-stick") {
         let puck_A, puck_B, joint_AB, spring_AB;
         
         let half_width_m = 0.20;
         let jointFromEnd_m = 0.0;
         let puckParameters = {'shape':'rect', 'half_height_m':0.05, 'half_width_m':half_width_m, 'color':'brown', 'borderColor':'gray', 'borderWidth_px':0, 'angle_r':0};
         let velocity_2d_m = new cP.Vec2D(0.0,0.0);
         
         let firstPuck = new cP.Puck( placement_2d_m, velocity_2d_m, puckParameters);
         
         let newPuckPosition_2d_m = placement_2d_m.copy();
         
         let n_pucks = 30;
         
         puck_A = firstPuck;
         
         for (let i = 1; i <= n_pucks; i++) {
            newPuckPosition_2d_m.addTo( new cP.Vec2D( half_width_m * 2, 0) );
            puck_B = new cP.Puck( newPuckPosition_2d_m, velocity_2d_m, puckParameters);
            
            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),
                                                      'visible':false}); 
            
            let localPoint_A = new cP.Vec2D( -half_width_m + jointFromEnd_m, 0.0);
            let localPoint_B = new cP.Vec2D( +half_width_m - jointFromEnd_m, 0.0);
            let length_m = ((half_width_m - jointFromEnd_m) * 4)  +  1 * half_width_m;
            spring_AB = new cP.Spring( puck_A, puck_B, {'spo1_ap_l_2d_m': localPoint_A , 'spo2_ap_l_2d_m': localPoint_B,
                                                        'color':'gray', 'unstretched_width_m':0.01, 'length_m':length_m, 'damper_Ns2pm2':0.30, 'strength_Npm':10.0, 'fixedLength':false,
                                                        'visible':false});
            puck_A = puck_B;
         }
      
      } else if (tableAction == "add-springy-chain") {
         addSpringyChain( placement_2d_m, {'n_pucks':30, 'groupIndex':-10, 'useSprings':true, 'puckColor':'orange', 'puckBorder_px':0});
               
      } else if (tableAction == "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 (tableAction == "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 (tableAction == "add-newtons-cradle-1mb") {
         // first, load in the 5h demo (3 moving balls)
         cR.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
            cR.saveState({'captureName':'4b-1m'});
         }, 100);
      } else if (tableAction == "add-newtons-cradle-3mb") {
         cR.demoStart_fromCapture(5, {'fileName':'demo5h.js'});
      */
         
      } else if (tableAction == "align-selected-pucks") {
         hostMSelect.align();
         
      } else if (tableAction == "arc-selected-pucks") {
         hostMSelect.arc( placement_2d_m);
         
      } else if (tableAction == "toggle-projectile-forecast") {
         gB.toggleProjectileForecast();
         
      } else if (tableAction == "toggle-SCM-display") {
         c.displaySCM = ! c.displaySCM; // System Center of Mass for all pucks
         
      } else if (tableAction == "toggle-MSC-display") {
         c.displayMSC = ! c.displayMSC; // Multi-Select Center
         
      } else if (tableAction == "bullets-from-pucks") {
         cP.Client.applyToAll( client => { 
            if (client.name.slice(0,3) != 'NPC') {
               client.puck.cannibalize = true;
               client.puck.bullet = true;
               client.bulletAgeLimit_ms = 60000; // 60s
               client.puck.linDamp = 0;
               client.puck.b2d.SetLinearDamping( client.puck.linDamp);
            }
         });
         
      } else if (tableAction == "modify-capture") {
         cR.modifyCapture();
         
      } else if (tableAction == "shift-capture") {
         cR.shiftCapture();
         
      }
      
      // 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 in support of the demos ////////////////////////////////////////
   
   function demoVersionBase( demoVersion) {
      var parts = demoVersion.split(".");
      return parts[0] + "." + parts[1];
   }
   
   function scrollDemoHelp( targetID, duration = 300) {  // 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 );
      }
   }
   
   // 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 resetToDefaults_gOnOff_RestAndFriction() {
      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) {
      let restitution, friction;
      let showMessage = setDefault( pars.showMessage, false);
      let updatePucks = setDefault( pars.updatePucks, true);
   
      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);
         restitution = cP.Puck.restitution_gOn;
         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);
         restitution = cP.Puck.restitution_gOff;
         friction =    cP.Puck.friction_gOff;
      }
      if (showMessage) {
         messages['help'].newMessage('Gravity = [base,yellow]' + cP.Puck.g_2d_mps2.y + '[base]', 1.0);
      }
      
      // If not fixed, set puck restitution and friction properties.
      if (updatePucks) {
         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 setNickNameWithoutConnecting() {
      let nickNameResult = hC.checkForNickName('normal');
      
      if (nickNameResult.status == 'too long') {
         hC.displayMessage('Nicknames must have 10 characters or less.');
         
      } else if (nickNameResult.status == 'too short') {
         hC.displayMessage('Nicknames must have more than 1 alphanumeric character.');
         
      } else if (nickNameResult.value) {
         hC.displayMessage('Your nickname is ' + nickNameResult.value + '.');
      }

      // If no nickname yet (unless there's modify-capture JSON in the chat field), put back the nickname reminder tip in the chat input field.
      if (nickNameResult.status != 'JSON') {
         if ( ! (clients['local'].nickName)) hC.restoreInputDefault( document.getElementById('inputField'));
      }
   }
      
   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 
      cR.saveState, cR.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 defaultStartingPosAndVel( demoVersion) {
      let startingPandV;
      
      if (demoVersion.slice(0,1) == '7') {
         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)} ];

      } else if (demoVersion.slice(0,3) == '8.a') {
         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 (demoVersion.slice(0,3) == '8.b') {
         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 (demoVersion.slice(0,3) == '8.c') {
         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 (demoVersion.slice(0,3) == '8.d') {
         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 (demoVersion.slice(0,3) == '8.e') {
         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 {
         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)} ];
      }
      
      return startingPandV;
   }
   
   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) cR.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; // border dimensions are initially set for hostCanvas in hostAndClient.css
      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 ([3,4,6,7,8].includes(j)) {
               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 + ":";
      }
      
      clearTable();
      gB.resetTableHistory();
      cP.Client.startingPandV = [];
      
      hostMSelect.candidateReportPasteDelete = null;
      
      // 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 number 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';
            }
         }
         
         client.bulletAgeLimit_ms = null;
      });
      
      setNickNameWithoutConnecting();
      cP.Client.resetScores();
      
      // De-select anything still selected.
      clients['local'].selectedBody = null;
      hostMSelect.resetAll();
            
      resetFenceColor( "white");
      if (dC.pause.checked) {
         dC.pause.checked = false;
      }
      
      // setPauseState will start the game loop only if it IS NOT running.
      c.frameCount = 0;
      setPauseState();
      // restartAnimationLoop will restart the loop only if it IS running.
      let notDemo0ExitFromFC = ( ! (document.fullscreenElement && (index ==0)) );
      if (restartLoop && notDemo0ExitFromFC) restartAnimationLoop( 200);
      
      // Turn gravity off by default.
      if (c.g_ON) {
         c.g_ON = false;
         dC.gravity.checked = false;
      }
      
      // Note that setGravityRelatedParameters runs at the end of demoStart.
      resetToDefaults_gOnOff_RestAndFriction();
      
      dC.comSelection.checked = true;
      
      pP.setBulletAgeLimit_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['win'].color = 'yellow';
      
      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
      // (see also "if (c.lockedAndLoaded)" block)
      if ( (state_capture) && (index == 3) && (state_capture.demoVersion.slice(0,3) == "3.d") ) {
         // Initiate new clients that don't have 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.
         // (see also "if (c.lockedAndLoaded)" block below where these are turned on for special demos)
         cP.Client.applyToAll( client => {
            client.ctrlShiftLock = false;
            client.poolShotLocked = false;
            client.poolShotLockedSpeed_mps = 0;
         });
      }
      
      if (index == 0) {
         cR.clearState();
         c.pauseErase = false;
         c.displaySCM = false;
         if (document.fullscreenElement) hC.changeFullScreenMode( canvas, 'off');
         scrollDemoHelp('scroll-to-very-top');
         dC.extraDemos.innerHTML = " reset";         
         messages['help'].newMessage("The zero key triggers a complete reset.\\   For a stronger reset, try reloading the page.", 3.0);         
                  
         // 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)) {
            cR.restoreFromState( state_capture);
            
            if ( ['1.c','1.d','1.e','1.f'].includes( demoVersionBase( c.demoVersion)) ) {
               // play the clack sound once at low (not zero) volume to make sure it is loaded and ready for the first collision.
               sounds['clack2'].play(0.05);
               
               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=\"cR.clearState(); gW.demoStart(1)\">&nbsp;a,</a>" +
            "<a title='a gentle landing' "   + hL('1.b') + " onclick=\"cR.demoStart_fromCapture(1, {'fileName':'demo1b.js'})\">&nbsp;b,</a>" +
            "<a title='calculating the first two digits of pi with collisions' " + hL('1.c') + " onclick=\"cR.demoStart_fromCapture(1, {'fileName':'demo1c.js'})\">&nbsp;c,</a>" +
            "<a title='three digits of pi' " + hL('1.d') + " onclick=\"cR.demoStart_fromCapture(1, {'fileName':'demo1d.js'})\">&nbsp;d,</a>" +
            "<a title='five digits of pi' " + hL('1.e') + " onclick=\"cR.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.restitution_gOff = 1.0;
         cP.Puck.friction_gOn =  0.6;
         cP.Puck.friction_gOff = 0.0;
         
         if ((state_capture) && (state_capture.demoIndex == 2)) {
            cR.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':'gray', 'colorSource':true, 'borderWidth_px':2,
                                                                  'createTail':true, 'tail':{'propSpeed_ppf_px':2, 'length_limit':35, 'color':'teal'} });
                                                                  
            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':'teal', 'borderWidth_px':0, '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':'gray', 'colorSource':true, 'borderWidth_px':2,
                                                                  'createTail':true, 'tail':{'propSpeed_ppf_px':2, 'length_limit':35, 'color':'teal'} });
         }
         
         dC.extraDemos.innerHTML = 
            "<a title='sound field'                          " + hL('2.a') + " onclick=\"cR.clearState(); gW.demoStart(2)\">&nbsp;a,</a>" +
            "<a title='pretty'                               " + hL('2.b') + " onclick=\"cR.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=\"cR.demoStart_fromCapture(2, {'fileName':'demo2c.js'})\">&nbsp;c,</a>" +
            "<a title='tag'                                  " + hL('2.d') + " onclick=\"cR.demoStart_fromCapture(2, {'fileName':'demo2d.js'})\">&nbsp;d,</a>" +
            "<a title='rainbow'                              " + hL('2.e') + " onclick=\"cR.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)) {
            cR.restoreFromState( state_capture);
            
            if (c.demoVersion.slice(0,3) == "3.d") {               
               c.canvasColor = '#2b473b'; // #36594a
               gB.resetGame(); // ghost-ball
            }
            
         } 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() {
               cR.saveState();
            }, 1);
            */
         }
         
         dC.extraDemos.innerHTML = 
            "<a title='order and disorder'      " + hL('3.a') + " onclick=\"cR.clearState(); gW.demoStart(3)\">&nbsp;a,</a>" +
            "<a title='no puck-puck collisions' " + hL('3.b') + " onclick=\"cR.demoStart_fromCapture(3, {'fileName':'demo3b.js'})\">&nbsp;b,</a>" +
            "<a title='no puck-puck collisions' " + hL('3.c') + " onclick=\"cR.demoStart_fromCapture(3, {'fileName':'demo3c.js'})\">&nbsp;c,</a>" +
            "<a title='pool shots' "              + hL('3.d') + " onclick=\"cR.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) {
         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)) {
            cR.restoreFromState( state_capture);
            
            if (c.demoVersion.slice(0,3) == "4.e") {  // Monkey Hunt game             
               c.canvasColor = '#324440'; 
               mH.initializeGame();
            }
            
         } 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=\"cR.clearState(); gW.demoStart(4)\">&nbsp;a,</a>" +
            "<a title='conservation of angular momentum...' " + hL('4.b') + " onclick=\"cR.demoStart_fromCapture(4, {'fileName':'demo4b.js'})\">&nbsp;b,</a>" +
            "<a title='no surface friction or y momentum' "   + hL('4.c') + " onclick=\"cR.demoStart_fromCapture(4, {'fileName':'demo4c.js'})\">&nbsp;c,</a>" +
            "<a title='little moves big' "                    + hL('4.d') + " onclick=\"cR.demoStart_fromCapture(4, {'fileName':'demo4d.js'})\">&nbsp;d&nbsp;</a>" +
            "<a title='get that monkey' "                     + hL('4.e') + " onclick=\"cR.demoStart_fromCapture(4, {'fileName':'demo4e.monkeyhunt.js'})\">&nbsp;e&nbsp;</a>";
         
         if (scrollHelp) {
            if ( c.demoVersion.includes('4.e.monkeyhunt') ) {
               scrollDemoHelp('#hunt');
            } else {
               scrollDemoHelp('#d4');
            }   
         } 
         
      } 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)) {
            cR.restoreFromState( state_capture);
            
            if (c.demoVersion.includes('basketball')) {
               c.canvasColor = '#262626';  // gray 262626 333333 (lighter)
               bpH.initializeGame();
            }        
            
         } else {
            cP.Wall.makeFence({}, canvas);
            
            // Spring triangle.
            var d5_puckPars_triangle = {'radius_m':0.20, 'restitution':0.0, 'friction':1.0};
            let xNudge_m = 0.10;
            
            var tri_vel_mps = new cP.Vec2D( 5.0, 0.0);
            new cP.Puck( new cP.Vec2D(1.00 + xNudge_m, 0.80 + Math.sqrt(3)/2.0), tri_vel_mps.scaleBy( 1.0), Object.assign({}, d5_puckPars_triangle, {'name':'puck1'}));
            
            tri_vel_mps.rotated_by(-120.0);
            new cP.Puck( new cP.Vec2D(1.50 + xNudge_m, 0.80                   ), tri_vel_mps.scaleBy( 1.0), Object.assign({}, d5_puckPars_triangle, {'name':'puck3'}));
            
            tri_vel_mps.rotated_by(-120.0);
            new cP.Puck( new cP.Vec2D(0.50 + xNudge_m, 0.80                   ), tri_vel_mps.scaleBy( 1.0), Object.assign({}, d5_puckPars_triangle, {'name':'puck2'}));
            
            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});
            
            // 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 springColor2 = 'yellow';
            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=\"cR.clearState(); gW.demoStart(5)\">&nbsp;a,</a>" +
            "<a title='Rube would like this...'  " + hL('5.b') + " onclick=\"cR.demoStart_fromCapture(5, {'fileName':'demo5b.js'})\">&nbsp;b,</a>" +
            "<a title='spring pendulum'          " + hL('5.c') + " onclick=\"cR.demoStart_fromCapture(5, {'fileName':'demo5c.js'})\">&nbsp;c,</a>" +
            "<a title='dandelion seeds'          " + hL('5.d') + " onclick=\"cR.demoStart_fromCapture(5, {'fileName':'demo5d.js'})\">&nbsp;d,</a>" +
            "<a title='chain-link loop using revolute joints' " + hL('5.e') + " onclick=\"cR.demoStart_fromCapture(5, {'fileName':'demo5e.js'})\">&nbsp;e,</a>" +
            "<a title='double-compound pendulum' " + hL('5.f') + " onclick=\"cR.demoStart_fromCapture(5, {'fileName':'demo5f.js'})\">&nbsp;f,</a>" +
            "<a title='wild west action'         " + hL('5.g') + " onclick=\"cR.demoStart_fromCapture(5, {'fileName':'demo5g.js'})\">&nbsp;g,</a>" +
            "<a title='Newton&#39;s cradle'      " + hL('5.h') + " onclick=\"cR.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') || c.demoVersion.includes('5.a.twinkle') ) {
               scrollDemoHelp('#d5a_soft');
            // basketball
            } else if ( c.demoVersion.includes('5.e.basketball') ) {
               scrollDemoHelp('#bphoops');
            // 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');
         
         c.g_ON = false;
         dC.gravity.checked = false;
         
         // This is an alternate way to fix the restitution and friction.
         cP.Puck.restitution_gOn =  0.0;
         cP.Puck.friction_gOn =  0.6;
         cP.Puck.restitution_gOff = 0.0;
         cP.Puck.friction_gOff = 0.6;
         
         if ((state_capture) && (state_capture.demoIndex == 6)) {
            cR.restoreFromState( state_capture);
         
         } else if ( demo_6_fromFile) {
            cR.restoreFromState( demo_6_fromFile);
            
         } else {
            cP.Wall.makeFence({}, canvas);
            jM.makeJello({});
         }
         
         jM.setUpPreGameHelp();
      
         // 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=\"cR.clearState(); gW.demoStart(6)\">&nbsp;a,</a>" +
            "<a title='the editor turned the jello into this...' " + hL('6.b') + " onclick=\"cR.demoStart_fromCapture(6, {'fileName':'demo6b.js'})\">&nbsp;b,</a>" +
            "<a title='the editor turned the jello into this...' " + hL('6.c') + " onclick=\"cR.demoStart_fromCapture(6, {'fileName':'demo6c.js'})\">&nbsp;c,</a>" +
            "<a title='a tough tangle...' " + hL('6.d') + " onclick=\"cR.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};
         
         cP.Puck.restitution_gOn =  0.6; 
         cP.Puck.friction_gOn =  0.0;
         
         cP.Puck.restitution_gOff = 0.6; 
         cP.Puck.friction_gOff = 0.0;
         
         pP.setBulletAgeLimit_ms(1000);
         
         if ((state_capture) && (state_capture.demoIndex == 7)) {
            networkPuckTemplate = cR.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));
         }
         
         if (state_capture && (state_capture.demoIndex == 7) && state_capture.startingPosAndVels) {
            cP.Client.startingPandV = state_capture.startingPosAndVels;
         } else {
            cP.Client.startingPandV = defaultStartingPosAndVel( c.demoVersion);
         }
         pP.createPucksForNetworkClients( canvas, networkPuckTemplate, cP.Client.startingPandV);
         pP.preGameSetUp( 7);
         
         dC.extraDemos.innerHTML = 
            "<a title='Puck Popper (1 drone on 4 pins)'  " + hL('7.a') + "  onclick=\"cR.clearState(); gW.demoStart(7)\">&nbsp;a,</a>" +
            "<a title='2 drones on 4 pins'               " + hL('7.b') + "  onclick=\"cR.demoStart_fromCapture(7, {'fileName':'demo7b.js'})\">&nbsp;b,</a>" +
            "<a title='4 drones on 5 pins'               " + hL('7.c') + "  onclick=\"cR.demoStart_fromCapture(7, {'fileName':'demo7c.js'})\">&nbsp;c,</a>" +
            "<a title='1 drone on 2 pins'                " + hL('7.d') + "  onclick=\"cR.demoStart_fromCapture(7, {'fileName':'demo7d.js'})\">&nbsp;d,</a>" +
            "<a title='cannibal inside springy chain'    " + hL('7.e') + "  onclick=\"cR.demoStart_fromCapture(7, {'fileName':'demo7e.js'})\">&nbsp;e&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};
         
         c.g_ON = false;
         dC.gravity.checked = false;
         
         // This applies only to pucks without fixed parameters:
         // Keep the restitution low (but not zero) for gOff situations (zero will produce bullets that act like clay). 
         // Low restitution helps the drones fly smoothly through the navigation channels in the terrain.
         // setGravityRelatedParameters runs after the drones are restored.
         cP.Puck.restitution_gOn =  0.7;
         cP.Puck.friction_gOn =  0.6;
         cP.Puck.restitution_gOff = 0.5;
         cP.Puck.friction_gOff = 0.6;
         
         pP.setBulletAgeLimit_ms(1500);
         
         if ((state_capture) && (state_capture.demoIndex == 8)) {
            networkPuckTemplate = cR.restoreFromState( state_capture);
         
         } else if (demo_8_fromFile) {
            // Don't need to parse here because read in from a file.
            networkPuckTemplate = cR.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 {
            jM.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});
            
         }
         
         if (state_capture && (state_capture.demoIndex == 8) && state_capture.startingPosAndVels) {
            cP.Client.startingPandV = state_capture.startingPosAndVels;
         } else {
            cP.Client.startingPandV = defaultStartingPosAndVel( c.demoVersion);
         }
         pP.createPucksForNetworkClients( canvas, networkPuckTemplate, cP.Client.startingPandV);
         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=\"cR.clearState(); gW.demoStart(8)\" style='cursor: pointer'>&nbsp;a,</a>" +
           "<a title='high-noon maze' " + hL('8.b') + " onclick=\"cR.demoStart_fromCapture(8, {'fileName':'demo8b.js'})\">&nbsp;b,</a>" +
           "<a title='wide open spaces (no drag)' " + hL('8.c') + " onclick=\"cR.demoStart_fromCapture(8, {'fileName':'demo8c.js'})\">&nbsp;c,</a>" +
           "<a title='bullet energy (no drag, and elastic collisions)' " + hL('8.d') +
                                                   " onclick=\"cR.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=\"cR.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)) {
            cR.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=\"cR.clearState(); gW.demoStart(9)\">&nbsp;a,</a>" +
            "<a title='colorful' " + hL('9.b') + " onclick=\"cR.demoStart_fromCapture(9, {'fileName':'demo9b.js'})\">&nbsp;b&nbsp;</a>";
         
      }
      
      // Now, after all the scripted and captured demos have loaded and possibly turned gravity on/off, update the gravity related stuff.
      setGravityRelatedParameters({"updatePucks":false});
      
      // 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();
      lB.logEntry( c.demoVersion);
      
      // Sometimes just want to be sure the user gets the fullscreen view.
      if (c.fullScreenDemo) {
         hC.changeFullScreenMode( canvas, 'on');
      }
      
      // Configure shooter for each client, and optionally set the shot speed.
      // c.lockedAndLoaded is set when the capture is restored (that's why this is at the end).
      // Also see "pool game locks and settings" section above.
      if (c.lockedAndLoaded) {    
         cP.Client.applyToAll( client => {
            // The dandelion demos, start with control-shift locked and a locked high-speed shot.
            if (c.demoVersion.slice(0,3) == "5.d") {
               client.ctrlShiftLock = true;
               client.poolShotLocked = true;
               client.poolShotLockedSpeed_mps = 200;
            
            // Start with control-shift locked, but shot speed variable.
            } else if (c.demoVersion.includes('basketball') || c.demoVersion.includes('monkeyhunt')) {
               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.
   */
   
   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 = Number( 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 = Number( 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');
      
      cR.initializeModule( canvas, ctx);
      gB.initializeModule( canvas, ctx);
      
      /////////////////////////////////////////////////////
      // Event handlers for local client (user input)
      /////////////////////////////////////////////////////
      
      // text area for JSON captures
      dC.json = document.getElementById('jsonCapture');
      dC.json.addEventListener("mousedown", function( e) {
         // right click to toggle size
         if (e.button == 2) {
            if (dC.json.style.width == "450px") {
               // back to normal size
               dC.json.style.width = "165px";
               dC.json.style.height = "30px";
            } else {
               // larger size
               dC.json.style.width = "450px";
               dC.json.style.height = "300px";
               window.scrollBy( 600, 0);
               window.setTimeout(function() {
                  window.scrollBy( 200, 0);
               }, 600);
            }
         } else if (e.button == 1) {
            e.preventDefault();
            cR.cleanCapture();
         }
      }, {capture: false});
      
      // 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 or regained focus");
            restartAnimationLoop( 600);
         } else {
            console.log("window minimized or lost focus");
         }
      });

      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.
         // The mouseOverElement call acts to restrict the handler and the prevention of default behavior to the canvas (scrolling still works in the left panel).
         if ( clients['local'].poolShotLocked  &&  mouseOverElement( canvas, clients['local'].raw_2d_px) ) {
            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);
         
         hostMSelect.candidateReportPasteDelete = null;
            
         if ((client.key_shift == "U") && (client.key_alt == "U") && (client.key_ctrl == "U")) {
            // Clicked on blank space on air table (un-selecting everything)
            if ( ! selected_b2d_Body) {
               // Un-select everything in the multi-select map.
               hostMSelect.resetAll();
               hostMSelect.selectModeIndex = 0;
            }
         } 
      }
      
      clearMultiSelect = function() {
         hostMSelect.resetAll();
         hostMSelect.selectModeIndex = 0;
         hostMSelect.candidateReportPasteDelete = null;
      }
      
      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});
      
      handleMouseOrTouchMove = function( 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, {'startOrEnd':'start', 'hostOrClient':'host', 'cl':clients['local'], 'socket':null, 
                                     'fromListener':fromListener, 'mK':null, 'ts':ts, 'raw_2d_px':raw_2d_px, 'demoVersionOnHost':c.demoVersion} );
         }
         
         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);
                           
                           // delete the platform walls in the Monkey Hunt game
                           if (c.demoVersion.slice(0,3) == '4.e') mH.deleteMonkeyWalls();
                           
                        } 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';
         
         } else {
            // Reset fine-moves if mouse and fingers are up.
            if (client.fineMovesState == 'on') {
               exitFineMoves( client.name);
            }
         }
         
         // 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, {'startOrEnd':'end', 'hostOrClient':'host', 'cl':clients['local'], 'socket':null, 
                                  'fromListener':'touchend', 'mK':null, 'ts':ts, 'raw_2d_px':null, 'demoVersionOnHost':c.demoVersion} );
         
         // Note: e.preventDefault() not needed here if the following canvas style is set
         // touch-action: none;
         
         if (clients['local'].mouseDown) {
            return;
            
         } else {
            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.
                     if ((hostMSelect.selectMode[ hostMSelect.selectModeIndex] == "springs") && (hostMSelect.candidateReportPasteDelete)) {
                        aT.springMap[ hostMSelect.candidateReportPasteDelete].interpret_editCommand( modifySpringCommand( command));
                     } else {
                        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) {
                     cR.demoStart_fromCapture(1, {'fileName':'demoSeries1b.js'});
                  } else if (c.demoLoopIndex == 1)  {
                     cR.demoStart_fromCapture(1, {'fileName':'demoSeries1c.js'});
                  } else if (c.demoLoopIndex == 2)  {
                     cR.demoStart_fromCapture(1, {'fileName':'demoSeries1d.js'});
                  } else if (c.demoLoopIndex == 3)  {
                     cR.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') && (clients['local'].key_shift == '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 (clients['local'].key_shift == 'D') {
                     cR.runCapture({'fromKeyBoard':true});
                  }
               
               } 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') { 
                  cR.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')) {
                  // Clear this zo there is no spring report to conflict with the spring-nature report.
                  clearMultiSelect();
                  
                  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;
                        delete spring.fixedLength;
                        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 lockedString = (spring.softConstraints_setInPars) ? " (locked)" : "";
                           if (spring.softConstraints) {
                              let softOrFixed = (spring.fixedLength) ? "fixed length" : "soft constraints";
                              springNature = "a [18px Arial,yellow]distance joint[18px Arial] with " + softOrFixed + lockedString;
                           } else {
                              springNature = "[18px Arial,yellow]Hooke's law[18px Arial]" + lockedString;
                           }
                        }
                        messages['help'].addToIt("\\[18px Arial,lightgray]     " + spring.name + " spring nature is " + springNature + "[base].");
                     });
                  }
                  
               
               // Toggle the lock on the pool shot 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'].resetMessage();
                     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 (and select) mode offered in the tab menu for multiselect.
               } else if (keyMap[e.keyCode] == 'key_tab') {
                  if (hostMSelect.count() > 1) {
                     if (hostMSelect.selectModeIndex < 3) {
                        hostMSelect.selectModeIndex++;
                        
                        hostMSelect.resetStepper();
                        
                        if (hostMSelect.selectMode[ hostMSelect.selectModeIndex] == "springs") {
                           // populate a list of names of the springs in the multiselect
                           cP.Spring.findAll_InMultiSelect( spring => hostMSelect.connectedNames.push( spring.name));
                           
                        } else if (hostMSelect.selectMode[ hostMSelect.selectModeIndex] == "revolute joints") {
                           cP.Joint.findAll_InMultiSelect( joint => hostMSelect.connectedNames.push( joint.name));
                           
                        } else {
                        }
                        
                     } else {
                        hostMSelect.selectModeIndex = 0;
                     }
                     messages['help'].newMessage("select and delete: [base,yellow]" + hostMSelect.selectModeMessage[ hostMSelect.selectModeIndex] + "[base]", 1.0);
                     
                  } else {
                     messages['help'].newMessage("Select at least two objects to view the 'tab' options for multiselect.", 1.5);
                  }
                  
               // Step through the springs and/or joints in the multiselect.
               } else if (keyMap[e.keyCode] == 'key_enter') {
                  
                  if (hostMSelect.selectMode[ hostMSelect.selectModeIndex] == "springs") {
                     hostMSelect.stepThroughArray( aT.springMap);
                  
                  } else if (hostMSelect.selectMode[ hostMSelect.selectModeIndex] == "revolute joints") {
                     hostMSelect.stepThroughArray( aT.jointMap);
                  }
                  
               // Delete stuff   
               } else if ((keyMap[e.keyCode] == 'key_x') && (clients['local'].key_ctrl == 'D')) {
                  
                  // First process multi-select
                  var foundSpringOrJoint = false;
                  let selectMode = hostMSelect.selectMode[ hostMSelect.selectModeIndex];
                  
                  if (hostMSelect.count() > 0) {
                     
                     if (['normal','springs','everything'].includes( selectMode)) {
                        if (hostMSelect.candidateReportPasteDelete) {
                           hostMSelect.deleteCandidate( aT.springMap);
                        } else {
                           // 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 joints','everything'].includes( selectMode)) {
                        if (hostMSelect.candidateReportPasteDelete) {
                           hostMSelect.deleteCandidate( aT.jointMap);
                        } else {
                           // 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( selectMode) && ( ! foundSpringOrJoint)) || (['everything'].includes( selectMode)) ) {
                        hostMSelect.applyToAll( msObject => msObject.deleteThisOne({}) );
                        // reset back to normal mode.
                        hostMSelect.selectModeIndex = 0;
                     }
                     
                  } else if (clients['local'].selectedBody) {
                     // A single-object selection.
                     if ((clients['local'].selectedBody.constructor.name == 'Puck') && (clients['local'].b2dSensor)) 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 a candidate was already selected...
                  if ((hostMSelect.selectMode[ hostMSelect.selectModeIndex] == "springs") && (hostMSelect.candidateReportPasteDelete)) {
                     cP.Spring.nameForPasting = hostMSelect.candidateReportPasteDelete;
                     hostMSelect.candidateReportPasteDelete = null;
                     hostMSelect.selectModeIndex = 0;
                     messages['help'].newMessage("[25px Arial,yellow]" + cP.Spring.nameForPasting + "[base] will be used as the source spring for copy and paste.", 3.0);
                     hostMSelect.resetAll();
                     
                  } else {
                     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 some springs 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;
                           // De-select all the springs on these two pucks (so the user doesn't have to click on empty space).
                           spring.selected = false;
                        });
                        
                        if (cP.Spring.nameForPasting) {
                           messages['help'].newMessage("[25px Arial,yellow]" + cP.Spring.nameForPasting + "[base] will be used as the source spring for copy and paste.", 3.0);
                        }
                        
                        // Added this mainly to be used in probing the name of a joint. Not used for copying the joint. Revolve joints are added via the pull-down menu or 
                        // corresponding keyboard shortcut.
                        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 a copy of a spring.
                        pasteSpring( false);
                     } else if (clients['local'].key_alt == 'D') {
                        // paste a new (default) spring.
                        pasteSpring( true);
                     }
                  } else if (c.demoVersion.slice(0,3)=='4.e') {
                     // Manually, using keyboard, advance to the next shot.
                     mH.setPositions({'disableAutoPosition':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_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 => {
                  // Don't do push-to-the-end operations on points intended to remain at the center.
                  if ( ! msObject.selectionPoint_l_2d_m.zeroLength()) centerThePoint( msObject, false);
               });
            }
            
         } else {
            // All clients can change the COM selection checkbox.
            dC.comSelection.click();  // 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, alt-shift-l : useful in ghost-ball for lining up trick shots
            } else if (client.key_alt == "D") {
               if (client.key_shift == "D") {
                  tableActions('arc-selected-pucks');
               } else {
                  hostMSelect.align();
               }
            }
         } else if (mode == 'keyup') {
         } else {
            console.log("not good to be in here...");
         }
      }
      
      pasteSpring = function( useNewSpring = false, pars={}) {
         // p (a usefully short name) is an array of non-wall pucks or pins.
         var p = [];
         let fixedLength = setDefault( pars.fixedLength, false);
         
         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) {
            function samePointSamePuck( springPuck, springPuckPoint, selectedPuck) {
               return ( springPuckPoint.equal( selectedPuck.selectionPoint_l_2d_m) && (springPuck.name == selectedPuck.name) );
            }
            
            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).
            cP.Spring.findAll_InMultiSelect( spring => {
               if (( samePointSamePuck( spring.spo1, spring.spo1_ap_l_2d_m, p[0])  &&  samePointSamePuck( spring.spo2, spring.spo2_ap_l_2d_m, p[1]) ) ||
                   ( samePointSamePuck( spring.spo2, spring.spo2_ap_l_2d_m, p[0])  &&  samePointSamePuck( spring.spo1, spring.spo1_ap_l_2d_m, p[1]) ) ) {
                  
                  sameLocalPointsWarning = spring.name; // already on target points
               }
            });
            
            if (useNewSpring) {
               if (sameLocalPointsWarning == "") {
                  
                  if (fixedLength) {
                     let p0_w_2d_m = p[0].worldPoint( p[0].selectionPoint_l_2d_m);
                     let p1_w_2d_m = p[1].worldPoint( p[1].selectionPoint_l_2d_m);
                     var length_m = p0_w_2d_m.subtract( p1_w_2d_m).length();
                  } else {
                     var length_m = 1.0;
                  }
                  
                  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':length_m, 'damper_Ns2pm2':0.30, 'strength_Npm':10.0, 'fixedLength':fixedLength});
                  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).
                  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. There can be a wall in there too.
            var p = [];
            
            // Check for at least one puck in the pair. Can't join two walls.
            let atLeastOnePuck = false;
            hostMSelect.applyToAll( msObject => {
               // Populate the p array so you can pass the pucks as parameters.
               p.push( msObject);
               if (msObject.constructor.name == "Puck") atLeastOnePuck = true;
            });
            
            // Check to see if these two objects are already connected by a revolute joint.
            let p_names = [p[0].name, p[1].name];
            let alreadyJoined = false;
            cP.Joint.findAll_InMultiSelect( joint => {
               if (p_names.includes( joint.jto1.name) && p_names.includes( joint.jto2.name)) {
                  alreadyJoined = true;
               }
            });
            
            if (atLeastOnePuck && ( ! alreadyJoined)) {
               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);
               
               hostMSelect.resetAll();
               
            } else if ( ! atLeastOnePuck) {
               messages['help'].newMessage('One of the selected objects must be a puck.', 3.0);
               
            } else if (alreadyJoined) {
               messages['help'].newMessage('Only one revolute joint is allowed per pair of objects.', 3.0);
            }
            
         } 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 fields in help panel for Ghost Ball, Monkey Hunt, Bipartisan Hoops.
      $('input.nickNameField').on('keyup blur', function(e) {
         if (e.key == "Enter") {
            $(this).blur();
            
            // Wait a bit just to be sure the blur event has time to process.
            // Note that the ES6 arrow function has the "this" of the surrounding context. 
            window.setTimeout( () => {
               
               if (this.id == 'nn_pool') {
                  if (c.demoVersion.slice(0,3) == "3.d") {
                     demoStart(3);
                  } else {
                     cR.demoStart_fromCapture(3, {'fileName':'demo3d.js'});
                  }
                  
               } else if (this.id == 'nn_monkeyHunt') {
                  if (c.demoVersion.slice(0,3) == "4.e") {
                     demoStart(4);
                  } else {
                     cR.demoStart_fromCapture(4, {'fileName':'demo4e.monkeyhunt.js'});
                  }
               
               } else if (this.id == 'nn_basketball') {
                  if (c.demoVersion.includes("5.e.basketball")) {
                     demoStart(5);
                  } else {
                     cR.demoStart_fromCapture(5, {'fileName':'demo5e.basketball.js'});
                  }
                  
               } else if (this.id == 'nn_popper7') {
                  demoStart(7);
                  
               } else if (this.id == 'nn_popper8') {
                  demoStart(8);
               }
               
               if (this.id != 'nn_popper7') hC.changeFullScreenMode( canvas, 'on');
               
            }, 100);
            
         
         } else if (e.type == "blur") {
            let inputString = $(this).val();
            let cleanString = inputString.replace(/\W/g, ''); // allow only alphanumeric and the underscore character
            
            // if too short, clean out the field
            if (cleanString.length < 2) cleanString = "";
            
            // sync all the nickname fields to this value
            $('input.nickNameField').val( cleanString);
         }
      });
      
      // 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;
         }
         
         // This immediately requests fullscreen and then calls restartAnimationLoop (which pauses the loop if not already paused).
         // The third parameter delays the restart that's in restartAnimationLoop. The restart will be 500ms after this next block
         // which resized the canvas to match the fullscreen viewport.
         hC.changeFullScreenMode( canvas, 'on', waitForFullScreen + 500);
         
         // Wait for the restart to finish before checking for a pause. The call will cause a single frame to get processed
         // if the loop is paused. Otherwise, a black screen results from fullCanvas if the animation loop is paused.
         oneFrameIfPaused( waitForFullScreen + 500 + 100);
         
         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.
            cR.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 {
            console.log('fullscreen state: FALSE');
            c.fullScreenState = false;
            canvas.style.borderWidth = '5px';
            /*
            If background color matches border color, it hides the border-edge 
            problem in Chrome. This fix is inhibited during times when the canvas 
            animation loop is stopped or when the screen erasing is stopped. These 
            conditions, coupled with going in and out of full-screen and full-canvas 
            mode, will leave the whole canvas colored in the border color. So have 
            to handle with care. In these cases, the fix is re-enabled in 
            setPauseState. Note: the problem can still be seen when exiting 
            full-screen (not full-canvas) mode when paused. It's a small gap by the 
            right-side border.
            */
            if (( ! c.pauseErase) && ( ! dC.pause.checked)) {
               canvas.style.backgroundColor = c.borderAndBackGroundColor; 
            }
            
            restartAnimationLoop( 500);
         }
      });
      
      // 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) {
            cR.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);
         }
      }     
      */
      
      // Update bullet age and clean out old bullets and unhealthy pucks.
      if ((c.demoIndex == 7) || (c.demoIndex == 8)) {
         pP.deleteOldandUnhealthy( c.deltaT_s);
      }
      
      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) && (jM.puckCount() > 0)) {
         jM.checkForJelloTangle();
      
      } else if (((c.demoIndex == 7 || c.demoIndex == 8)) && ( ! pP.getNpcSleep())) {
         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
         c.frameCount++;
         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 => {if (spring.name != hostMSelect.candidateReportPasteDelete) spring.selected = true});
      cP.Joint.findAll_InMultiSelect( joint => {if (joint.name != hostMSelect.candidateReportPasteDelete) joint.selected = true});
      
      cP.Spring.applyToAll( spring => {
         // For a single spring, write report.
         if ( ((cP.Spring.countInMultiSelect == 1) && (spring.inMultiSelect())) || ((hostMSelect.candidateReportPasteDelete) && (spring.name == hostMSelect.candidateReportPasteDelete)) ) {
            spring.report();
         }
         spring.draw( ctx);
      });
      
      cP.Pin.applyToAll( pin => {
         pin.draw( ctx, pin.radius_px);
      });
      
      cP.Joint.applyToAll( joint => {
         if ( ((cP.Joint.countInMultiSelect == 1) && (cP.Spring.countInMultiSelect <= 1) && (joint.inMultiSelect())) || ((hostMSelect.candidateReportPasteDelete) && (joint.name == hostMSelect.candidateReportPasteDelete)) ) {
            joint.report();
         }
         joint.draw( 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.selectedBody.clientName) client.selectedBody.drawClientName( ctx, c.deltaT_s, {'stayOn':true});
         }
         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
         }
      });
      
      // Draw a marking circle on each object in the multi-select map.
      if (hostMSelect.count() > 0) {
         hostMSelect.applyToAll( msObject => msObject.draw_MultiSelectPoint( ctx) );
         if ((hostMSelect.count() > 1) && (c.displayMSC)) hostMSelect.drawCenter( ctx);
      }
      
      // Draw mark for SCM
      if (c.displaySCM) cP.Puck.drawSystemCenterOfMass( ctx);
      
      if ( (['3.d','4.e','5.e'].includes( c.demoVersion.slice(0,3))) || [7,8].includes( c.demoIndex) ) {
         messages['score'].displayIt( c.deltaT_s, ctx);
         if (c.demoVersion.slice(0,3) == '3.d') 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
      
      // Client cursors
      cP.Client.applyToAll( client => {
         if ( (client.name.slice(0,3) != 'NPC') && (client.deviceType != 'mobile') && ( ! client.twoThumbsEnabled) ) { 
            client.drawCursor( ctx);
         }
      });
      
      // 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).
   
   Also note that arrays are mutable objects and can be revealed here. But 
   beware if you need to do filter operations or reset the array by assigning
   to a new empty array. This will leave a hanging reference to the old array
   in the return section. Using a functional interface to the array will avoid
   these problems (see jelloMadness.js and its m_jelloPucks array). Otherwise, 
   be careful, avoid filters, and use a pop loop if you need to empty out the 
   array. Another option is the put the array in a wrapper object and reveal 
   that object.
   */

   return {
      // Objects
      world: world,
      
      tableMap: tableMap,
      hostMSelect: hostMSelect,
      clients: clients,
      sounds: sounds,
      dC: dC,
      keyMap: keyMap,
      messages: messages,
      aT: aT,
      getPiCalcEngine: function() { return piCalcEngine; }, // instantiated in demo #1
      
      // Variables
      getG_ON: function() { return c.g_ON; },
      setG_ON: function( val) { c.g_ON = val; },
      
      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; },
      
      getFrameCount: function() { return c.frameCount; },
      
      getChatLayoutState: function() { return c.chatLayoutState; },
      
      getDemoVersion: function() { return c.demoVersion; },
      setDemoVersion: function( val) { c.demoVersion = val; },
      
      getDemoIndex: function() { return c.demoIndex; },
      
      getPauseErase: function() { return c.pauseErase; },
      
      getLagTesting: function() { return c.lagTesting; },
      
      getSoftConstraints_default: function() { return c.softConstraints_default; },
      
      getLastClientToScoreHit: function() { return c.lastClientToScoreHit; },
      
      getFullScreenDemo: function() { return c.fullScreenDemo; },
      setFullScreenDemo: function( val) { c.fullScreenDemo = val; },
      
      getLockedAndLoaded: function() { return c.lockedAndLoaded; },
      setLockedAndLoaded: function( val) { c.lockedAndLoaded = val; },
      
      getPiCalcs: function() {return {'enabled': c.piCalcs.enabled, 'clacks':c.piCalcs.clacks, 'usePiEngine':c.piCalcs.usePiEngine}; },
      setPiCalcs: function( enabled, clacks, usePiEngine) { c.piCalcs.enabled = enabled, c.piCalcs.clacks = clacks, c.piCalcs.usePiEngine = usePiEngine; }, 
      
      // Methods
      b2d_getPolygonVertices_2d_px: b2d_getPolygonVertices_2d_px,
      b2d_getPolygonVertices_2d_m:  b2d_getPolygonVertices_2d_m,
      b2d_getBodyAt: b2d_getBodyAt,
      
      toggleMultiplayerStuff: toggleMultiplayerStuff,
      
      setClientCanvasToMatchHost: setClientCanvasToMatchHost,      
      adjustSizeOfChatDiv: adjustSizeOfChatDiv,
      updateClientState: updateClientState,
      
      init: init,
      getCanvasDimensions: getCanvasDimensions,
      setPauseState: setPauseState,
      startit: startit,
      stopit: stopit,
      setFrameRate: setFrameRate,
      tableActions: tableActions,
      freeze: freeze,
      stepAnimation: stepAnimation,
      restartAnimationLoop: restartAnimationLoop,
      setGravityRelatedParameters: setGravityRelatedParameters,
      stopRotation: stopRotation,
      reverseDirection: reverseDirection,
      toggleElementDisplay: toggleElementDisplay,
      toggleSpanValue: toggleSpanValue,
      key_ctrl_handler: key_ctrl_handler,
      key_b_handler: key_b_handler,
      scrollDemoHelp: scrollDemoHelp,
      openDemoHelp: openDemoHelp,
      fullScreenState: fullScreenState,
      demoVersionBase: demoVersionBase,
      demoStart: demoStart,
      
      clearCanvas: clearCanvas,
      clearTable: clearTable
   };
   
})();