11
\$\begingroup\$

For fun, I have been working on this game I called Star Catcher. It's a very basic game where stars are falling and you try to catch as many of them in your bucket before you miss 10. It's a real mess of code that I just threw into one file, basically. The animations seem to be working fairly smooth for me on my computer.

How can I improve the smoothness of the animations on mobile? Something I have noticed is that when I just leave the stars to fall without moving the bucket by touch dragging, it seems to be very smooth. I started with the animations using jQuery animate and using the step function to check for star, bucket collisions. I then compared adding velocity.js, transit.js and gsap's jQuery plugin to see if that would help. I also compared having a full screen canvas that the stars were painted to, bucket as is DOM and then stars and bucket both painted on canvas.

I ended up going with gsap. No canvas and checking for collisions using requestAnimationFrame, but still running into this issue of moving the bucket and the stars looking choppy only on my phone. Any ideas? Do you think converting the gsap animations from their jQuery plugin to their tweenmax or whatever would help? I've had similar issues with past projects (circlebattle.com). I did have plans to use cordova to convert -> app blah blah blah but as is the games potential for mobile is being held back. I understand that many are going to say you have to develop the app natively for it to really be responsive, but really? The game is very simple. Yes, it is a bit DOM-intensive with each star being its own element, but it seems like my phone should be able to handle this simplistic of game better. On my todo list is networking it so players can pair up and one person drop stars down and the other tries to catch them.

If you have any other ideas to improve the game in general, I am also open.

  // shim layer with setTimeout fallback
  window.requestAnimFrame = (function(){
    return  window.requestAnimationFrame       ||
            window.webkitRequestAnimationFrame ||
            window.mozRequestAnimationFrame    ||
            function( callback ){
              window.setTimeout(callback, 1000 / 60);
            };
  })();
  var height, width;
  var levelData = JSON.parse('[{"x":1,"time":941},{"x":8,"time":2399},{"x":20,"time":4096},{"x":35,"time":5759},{"x":51,"time":8153},{"x":69,"time":9817},{"x":78,"time":10399},{"x":61,"time":11367},{"x":46,"time":13029},{"x":27,"time":14585},{"x":15,"time":15545},{"x":41,"time":16423},{"x":62,"time":17735},...]');
  var myTimeouts = [];
  var inGame = false;
  var gamePaused = false;
  var starStartY = -50;
  var starX, starY, bucketPos;
  var imgPreloads = ['img/fireworks.gif', 'img/golden-star-sml.png'];
  var engagement = 'click';
  var starsFallen;
  var starsCaught;
  var mySocket;
  var userObj;
  var needtoshowhs = false;
  var bucketTop, bucketBottom;
  var mouseCount = 0;
  var nocounts = [];
  function Timer(fn, countdown) {
      var ident, complete = false;
      function _time_diff(date1, date2) {
          return date2 ? date2 - date1 : new Date().getTime() - date1;
      }
      function cancel() {
          window.clearTimeout(ident);
      }
      function pause() {
          window.clearTimeout(ident);
          total_time_run = _time_diff(start_time);
          complete = total_time_run >= countdown;
      }
      function resume() {
          ident = complete ? -1 : window.setTimeout(fn, countdown - total_time_run);
      }
      var start_time = new Date().getTime();
      ident = window.setTimeout(fn, countdown);
      return { clear: cancel, pause: pause, resume: resume };
  }
  var moveBucket = function(x) {
    bucketPos = x;
    // bucketTop.css("left", x + 'px');
    // bucketBottom.css("left", x + 'px');
    bucketTop.stop()
    bucketTop.animate({left: x+ 'px'}, 5);
    bucketBottom.stop();
    bucketBottom.animate({left: x+ 'px'}, 5);
  };
  var showHighScores = function() {
    mySocket.emit('getHS');
    needtoshowhs = true;
  };
  var animateStar = function(starEl, forceDuration) {
    starEl.animate({top: height}, {
      easing: "linear",
      duration: forceDuration || starEl.data('duration'),
      complete: function() {
        console.log('complete');
        if (inGame && !gamePaused) {
          var index = nocounts.indexOf($(this).data('duration'));
          if (index == -1) {
            starsFallen++;
            $('#starsfallen').text(starsFallen);

            if (starsFallen == 10) {
              endOfGame(function() {
                if (starsCaught > userObj.starsCaught) {
                  // mess, fadespeed, delay, displaylength, cb)
                  displayMessage('congrats - new personal best!<br>' + starsCaught + ' stars caught before 10 misses!', 'slow', 20, 3000, function() {
                    showHighScores();
                  });
                  mySocket.emit('sendScore', {
                    username: userObj.username,
                    handshake: userObj.handshake,
                    starsCaught: starsCaught
                  });
                } else {
                  displayMessage('stars caught before 10 misses...' + starsCaught, 'slow', 20, 3000, function() {
                    showHighScores();
                  });
                }
              });
            }
          } else {
            nocounts.splice(index, 1);
          }
        }
        starEl.remove();
      }
    });
  };
  var dropStar = function(x) {
    if (inGame && !gamePaused) {
      var starEl = $('<img src="img/golden-star-sml.png" class="star">');
      x = (x) ? width * x / 100 : Math.round(Math.random() * (width-50) );
      starEl.css('left', x + 'px' );
      starEl.css('top', starStartY + 'px');
      $('#gamearea').append(starEl);
      var duration = 1600 + Math.round(Math.random() * 2000) - starsCaught*3.5;
      if (duration < 1500) {
        starEl.addClass('fast-star');
      }
      starEl.data('duration', duration);
      setTimeout(function() {
        if (!gamePaused) {
          animateStar(starEl);
        } else {
          starEl.remove();
        }
      }, 250);
    }
  }
  var gameWinner = function() {
    endOfGame(function() {
      ga('send', 'winner');
      $('body').append('<div id="fireworks"><h1></h1></div>').hide().fadeIn();
      setTimeout(function() {
        $('#fireworks h1').text('Congratulations');
        $('#fireworks h1').animate({fontSize: '1000px'}, 6000, 'easeInCubic', function() {
          $('#fireworks h1').text('1000 Points!!!!!');
          $('#fireworks h1').css('font-size', '1px');
          $('#fireworks h1').animate({fontSize: '2000px'}, 6000, 'easeInCubic', function() {
            setTimeout(function() {
              $('#fireworks').fadeOut('slow', function() {
                startGame();
                $('#fireworks').remove();
              });
            }, 2000);
          });
        });
      }, 2500);
    });
  };
  var pauseGame = function() {
    $('.star').each(function() {
      var curCoords = $(this).position();
      var dur = $(this).data('duration');
      nocounts.push($(this).data('duration'));
      $(this).stop();
      var pauseStar = $('<img src="img/golden-star-50.png" class="star star-paused">');
      console.log(curCoords);
      pauseStar.css('left', curCoords.left + 'px');
      pauseStar.css('top', curCoords.top + 'px');
      pauseStar.data('duration', dur);
      $('#gamearea').append(pauseStar);
    });
    for (var i = 0; i < myTimeouts.length; i++) {
      console.log('pausing');
      myTimeouts[i].pause();
    }
    $('body').append('<h1 class="message">game paused<br>double ' + engagement + ' to resume</h1>');
    gamePaused = !gamePaused;
  };
  var resumeGame = function() {
    $('.message').remove();
    console.log('resumeing')
    $('.star').each(function() {
      var newDuration = (height - $(this).position().top) / height * $(this).data('duration');
      animateStar($(this), newDuration);
      $(this).removeClass('star-paused');
    });
    for (var i = 0; i < myTimeouts.length; i++) {
      myTimeouts[i].resume();
    }
    gamePaused = !gamePaused;
  };
  var endOfGame = function(cb) {
    if (inGame) {
      inGame = false;
      $('#scoreboard').animate({top: '-200px'}, 2000, function() {
        for (var i=0; i<myTimeouts.length; i++) {
          myTimeouts[i].clear();
        }
        timesouts = [];
        $('#bucket').hide();
        $('.star').each(function() {
          $(this).remove();
          nocounts.push($(this).data('duration'));
        });
        cb();
      });
    }
  };
  var startGame = function() {
    ga('send', 'startgame');
    mySocket.emit('startgame');
    $('#hs').fadeOut();
    starsCaught = 0;
    starsFallen = 0;
    $('#starscaught').text(starsCaught);
    $('#starsfallen').text(starsFallen);
    $('#bucket').show();
    $('.message').remove();
    // handle levelData
    levelData.forEach(function(star, index) {
      myTimeouts.push(new Timer(function() {
        dropStar(star.x);
        myTimeouts.splice(index, 1);
      }, star.time));
    });
    // got to end of level
    myTimeouts.push(new Timer(function() {
      endOfGame(function() {
        ga('send', 'endlose');
        $('body').append('<h1 class="message">Better luck next time.</h1>');
        setTimeout(function() {
          startGame();
        }, 6000);
      });
    }, 559999));  // ~ 9 minutes
    inGame = true;
  };
  var screenSaverStars = function() {
  };
  $(function() {
    height = $(window).height();
    width = $(window).width();
  });
  $(window).resize(function() {
    height = $(window).height();
    width = $(window).width();
  });
  var displayMessage = function(mess, fadespeed, delay, displaylength, cb) {
    setTimeout(function() {
      $('#directions h1').html(mess);
      $('#directions').fadeIn(fadespeed);
      if (displaylength) {
        setTimeout(function() {
          $('#directions').fadeOut();
          if (cb) cb();
        }, displaylength);
      }
    }, delay);
  };
  $(document).ready(function() {
      imgPreloads.forEach(function(imgUrl) {
        $('body').append('<img src="' + imgUrl + '" class="img-preload">');
      });
      $(window).bind(
        'touchmove',
         function(e) {
          e.preventDefault();
        }
      );
      var $dragging = null;
      if ('ontouchstart' in document.documentElement) {
        engagement = 'tap';
        starStartY = -200;
        $(document.body).on("touchstart", "#bucket", function (e) {
            $dragging = $('#bucket');
            if (!inGame) {
              $('#hs').fadeOut();
              $('#directions').fadeOut('slow', function() {
                $('#scoreboard').animate({top: '10px'}, 2000, function() {
                  startGame();
                });
              });
            }
        });
        $(document.body).on("touchmove", function(e) {
            if ($dragging) {
              moveBucket(e.originalEvent.touches[0].pageX-50);
            }
            mouseCount++;
        });
        $(document.body).on("doubletap", function(e) {
          if (inGame) {
            if (gamePaused) {
              resumeGame();
            } else {
              pauseGame();
            }
          }
        });
      } else {
        $(document.body).on("mousedown", "#bucket", function (e) {
            $dragging = ($dragging) ? null : true;
            if (!inGame) {
              $('#hs').fadeOut();
              $('#directions').fadeOut('slow', function() {
                $('#scoreboard').animate({top: '10px'}, 2000, function() {
                  startGame();
                });
              });
            }
        });
        $(document.body).on("mousemove", function(e) {
            if ($dragging) {
                moveBucket(e.pageX-50);
            }
        });
        $(document.body).on("dblclick", function(e) {
          if (inGame) {
            if (gamePaused) {
              resumeGame();
            } else {
              pauseGame();
            }
          }
        });
      }
      $('#hs-mess').text(engagement + ' the bucket to start new game');
      var checkForCollisions = function() {
          $('.star').each(function() {
            starX = $(this).position().left;
            starY = $(this).position().top;
            // check for bucket catch
            // console.log(starX, starY, bucketPos)
            if (starY > height - 10 - 86 - 10 && starY < height - 86) {
              if (starX > bucketPos - 30 && starX < bucketPos + 100) {
                nocounts.push($(this).data('duration'));
                $(this).stop();
                $(this).remove();
                console.log(nocounts);
                starsCaught++;
                $('#starscaught').text(starsCaught);;
                if (starsCaught === 1000) {
                  gameWinner();
                }
              }
            }
          });
          requestAnimFrame(checkForCollisions);
      };
      $(window).load(function() {
        requestAnimFrame(checkForCollisions);
       // executes when complete page is fully loaded, including all frames, objects and images
       $('#gamearea').fadeIn('slow', function() {
         if (docCookies.hasItem('userStatus')) {
           userObj = JSON.parse(docCookies.getItem('userStatus'));
           mySocket.emit('verifyLogin', userObj);
           console.log('verify', userObj);
           $('#personal_best').text(userObj.starsCaught);
         } else {
           displayMessage('welcome to Star Catcher', 'slow', 200, 2000, function() {
             setTimeout(function() {
               $('#username').focus();
             }, 1600);
             if (engagement === 'click') {
               displayMessage('<table><tr><td colspan=2><h1>create a username</h1></td></tr><tr><td><input type="text" id="username"></td><td><button id="createuser" type="submit">submit</button></td></tr><tr><td colspan=2 id="username-response"></td></tr></table>', 'fast', 1500);
             } else {
               displayMessage('<h1>create a username</h1><input type="text" id="username"><br><button id="createuser" type="submit">submit</button><div id="username-response"></div>', 'fast', 1500);
             }
                });
         }
         mySocket.emit('getHS');
       });
       $('#loading').remove();
      });
      $(window).on('blur', function() {
        if (inGame && !gamePaused) {
          console.log('window blur paused')
          pauseGame();
        }
      });
      $(document).on('click', '#createuser', function() {
        console.log('click');
        mySocket.emit('usernameSubmit', {
          username: $('#username').val()
        });
      });
      $(document).on('click', '#viewhs', function() {
        $('#directions').fadeOut(2000, function() {
          setTimeout(function() {
            $('#hs').slideDown({
                duration: 1800,
                easing: 'linear'
            });
          }, 0);
        });
      });
      $(document).on('keypress', '#username', function(event) {
        if (event.which == 13) {
            event.preventDefault();
            $("#createuser").trigger('click');
        }
      });
      // $(document.body).on("mouseup", function (e) {
      //     if ($dragging) $dragging = null;
      // });
      $('img').on('dragstart', function(event) { event.preventDefault(); });
      mySocket = io();
      mySocket.on('username-feedback', function(data) {
        $('#username-response').removeClass('good bad');
        $('#username-response').addClass(data.res);
        $('#username-response').text('response: ' + data.msg);
        if (data.res === 'good') {
          $('#createuser').prop("disabled",true);
          docCookies.setItem('userStatus', JSON.stringify({
            username: $('#username').val(),
            starsCaught: '0',
            handshake: data.handshake
          }), 31536e3, "/");
          userObj = {
            username: $('#username').val(),
            starsCaught: '0',
            handshake: data.handshake
          };
          $('#personal_best').text('0');
          setTimeout(function() {
            $('#directions').fadeOut('slow', function() {
              displayMessage('the goal is to catch as many stars as possible before you miss 10', 'fast', 700, 1800, function() {
                displayMessage(engagement + ' the bucket to start', 'slow', 500);
                setTimeout(function() {
                  addBucket();
                }, 500);
              });
            });
          }, 1000);
        }
      });
      mySocket.on('login-feedback', function(data) {
        if (data.res) {
          displayMessage('welcome back to Star Catcher,<br><span id="usr">' + userObj.username + '</span><hr>' + engagement + ' the bucket to start<br><button id="viewhs">...or view leaderboard</button>', 'slow', 200);
          setTimeout(addBucket, 200);
        } else {
          displayMessage('hackz0r');
          setTimeout(function() {
            docCookies.removeItem('userStatus');
            location.reload();
          }, 4000);
        }
      });
      mySocket.on('hsSuccess', function(data) {
        userObj.starsCaught = data.starsCaught;
        userObj.handshake = data.handshake;
        docCookies.setItem('userStatus', JSON.stringify({
          username: userObj.username,
          starsCaught: data.starsCaught,
          handshake: data.handshake
        }), 31536e3, "/");
        mySocket.emit('');
        $('#personal_best').text(data.starsCaught);
      });
      mySocket.on('hs', function(data) {
        console.log(JSON.stringify(data.scores));
        $('#hs table tbody').empty();
        for (var i = 0; i < 10; i++) {
          $('#hs table tbody').append('<tr><td></td><td></td><td></td><td></td></tr>');
        }
        data.scores.forEach(function(score, i) {
          debugger;
          var theDate = (engagement === 'tap') ? score.dateset.substring(0,5) : score.dateset;
          $('#hs table tbody tr').eq(i).html([i+1, score.username, theDate, score.starscaught].map(function(what) {
            return "<td>" + what + "</td>";
          }));
          if (userObj && score.username === userObj.username) {
            $('#hs table tbody tr').eq(i).children().addClass('me');
          }
        });
        if (needtoshowhs) {
          $('#hs').fadeIn();
          needtoshowhs = false;
          setTimeout(function() {
            moveBucket(10);
            $dragging = null;
            setTimeout(function() {
              $('#bucket').show();
            }, 500);
          }, 1200);
        }
        if (engagement === "tap") {
          $('#hs td').addClass('mobilehstd');
        }
      })
  });
  var addBucket = function() {
    var theBucket = $('<div id="bucket"><img src="img/topbucket.png" id="top"><img src="img/bottombucket.png" id="bottom"></div>').hide().fadeIn();
    $('body').append(theBucket);
    bucketTop = $('#top');
    bucketBottom = $('#bottom');
  }

The game can be played here and the source code is here.

\$\endgroup\$
  • \$\begingroup\$ You should check for duration<0 as I think that could mean many stars are placed but never move. \$\endgroup\$ – N74 Jan 21 '16 at 8:13
  • \$\begingroup\$ Unfortunately, you must include all your code in the question, ending at if (starsFallen == 10) { endOfGame(function() { is not how we do things here. Your question is really good, and the sooner you add all your code, the sooner I can complete my review! \$\endgroup\$ – Quill Jan 21 '16 at 8:31
  • \$\begingroup\$ @N74 the highest starscaught have been 170 or so \$\endgroup\$ – John Murphy Jan 21 '16 at 9:07
  • \$\begingroup\$ and @Quill updated with more of the code- wanted to put the focus on the animations themselves and didnt want to to overwhelm you with my spaghetti code \$\endgroup\$ – John Murphy Jan 21 '16 at 9:07
  • \$\begingroup\$ @Quill you got me- i knew i should have implemented reasonability for artificially high score submissions before i posted it here. go ahead and try that again. \$\endgroup\$ – John Murphy Jan 21 '16 at 9:23
11
\$\begingroup\$

Your code is good, but you have one massive oversight:

It's hackable.

enter image description here

Your code shouldn't be accessable from the Console. I would recommend using getters and setters to limit incorrect addition:

var protectedScore = 0; //abstracted away where it cannot publicly be reached

GameClass.__defineSetter__("score", function(scoreToSet){
    if (scoreToSet != protectedScore + 1){
        protectedScore -= 50; //Cheaters' Penality
        return;
    }
    protectedScore++
}

Or something similar.


Hacked Version 2

You updated your code on the website (although it still applies to the code above), so I figured I'd give it a second go:

lol

After you patched the simple starsCaught = 1e10 hack, I had to get a little more clever.

I had two things in mind:

  1. Make the bucket width 100%, so every star would hit it. (Thwarted by the bucketPos variable, but I'm sure I could work around that with the next method, if I tried)
  2. Reset the starsFallen variable so that I could just simply play the game without possibility of losing.

This time, my code consisted of:

window.cheat = function(){ window.starsFallen = 0 }
setInterval(window.cheat, 500)

and when I wanted the game to end, I just used your end game function:

    endOfGame(function() {
      if (starsCaught > userObj.starsCaught) {
        // mess, fadespeed, delay, displaylength, cb)
        displayMessage('congrats - new personal best!<br>' + starsCaught + ' stars caught before 10 misses!', 'slow', 20, 3000, function() {
          showHighScores();
        });
        mySocket.emit('sendScore', {
          username: userObj.username,
          handshake: userObj.handshake,
          starsCaught: starsCaught
        });
      } else {
        displayMessage('stars caught before 10 misses...' + starsCaught, 'slow', 20, 3000, function() {
          showHighScores();
        });
      }
    });

debugger

Your code has a debugger call in the forEach loop, I would remove this from production code on your website. It's test-code at best.


starsFallen:

Like I said above, I would place limitations on the starsFallen variable that means it cannot be decremented, only incremented. Otherwise a simple interval hack is possible.


Making your variables & functions public

For games like this, it's better to have the JavaScript stored in an external file and minified/obfuscated. That would at least stop script kiddies (and probably even me)

Consider hosting the sensitive game logic on your server, and simply have the client make sockets calls like: starCaught()


setTimeout

I would consider other forms of animation tracking than setTimeout, as assigning lots and lots of time based function callbacks would seriously kill performance.

\$\endgroup\$
  • 3
    \$\begingroup\$ +1, never handle sensitive game logic (i.e, score count) on the client. It can - and will - be abused. \$\endgroup\$ – Dan Pantry Jan 21 '16 at 9:53
  • \$\begingroup\$ it has been updated go ahead and try to hack it \$\endgroup\$ – John Murphy Jan 21 '16 at 10:07
  • \$\begingroup\$ thank you for the heads up on the stray debugger. i do have a ton of settimeouts...is there another function i should be using that allows me to run something 200 ms in the future that works better? \$\endgroup\$ – John Murphy Jan 21 '16 at 10:08
  • 1
    \$\begingroup\$ I think it's redundant to call clientside JS hackable, especially to judge it on that; it's clientside, and will always be fundamentally hackable. If one was very intent, they could use a tool like tamperdata to modify submitted high scores, or even take it farther using networking tools to tamper with the source. \$\endgroup\$ – JavaProphet Jan 27 '16 at 1:19
  • 3
    \$\begingroup\$ Obviously hackable and hackable with experience are two separate things, as I pointed out. Publicly accessible client-side code are always vulnerable, it's just how vulnerable it is that you should be considering. \$\endgroup\$ – Quill Jan 27 '16 at 1:48

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.