Hoppy Grace explainer

Happy April from the Grasshopper team!

To start the game, set the value of changeColor from false to true. After running the code, tap on the grasshopper to begin the game.

Other Variables

The game is activated by setting the changeColor variable to true, but there are other variables you can play around with to change how the game is played:

autopilot: If autopilot is set to true, the game will play all by itself.

binary: Set binary to true to see your score in binary.

ceiling: Set ceiling to false to allow the grasshopper to hop above the screen.

collisions: Set collisions to false to allow the grasshopper to move through walls.

dizzyMode: Set dizzyMode to true to make the screen rotate while you play.

floor: Set floor to false to allow the grasshopper to fall below the screen.

laser: Set laser to true to draw a line between the grasshopper and the gap it needs to hop through.

words: Set words to false to turn off the words in the background of the game.

The Code:

Expand the code block below to see the code behind Hoppy Grace. Don’t worry if you see code or concepts that look unfamiliar. The purpose of displaying this code is to show you what JavaScript can do!

Tap here to see the code behind the game!
/**
* User Flags
*/

let changeColor = false;
let ceiling = true;
let floor = true;
let collisions = true;
let dizzyMode = false;
let binary = false;
let autopilot = false;
let laser = false;
let words = true;


/**
 * Window Size Constants
 */

const WIW = window.innerWidth || 368;
const WIH = window.innerHeight || 231;

const CX = WIW / 2;
const CY = WIH / 2;


/**
* Initial Conditions
*/

// event type (click or touch)
let eventType;

// distance traveled
let x = 0;

// timestep in milliseconds
const dt = 1000 / 60;

// initial vertical position
let y = CY;

// initial vertical velocity
let dy = 0;

// vertical acceleration
const ddy = 0.0005;

// box size
const sideLength = 30;

// number of boxes in each column
let colSize = 5;

// number of box widths from top to bottom of each hole
let holeHeight = 3;

// number of box widths to separate each wall
let gap = 3;

// distance from the beginning of one wall to the next
const holeTohole = sideLength * (1 + gap);

// number of pixels to shrink bounding box of grasshopper
const tolerance = 5;

// number of taps
let taps = 0;

// record of where gaps were
let gaps = [];

// CS words - must end each string with a semicolon
const wordList = [
  "STRING;",
  "ARRAY;",
  "INDEX;",
  "VARIABLE;",
  "OBJECT;",
  "OPERATOR;",
  "BOOLEAN;",
  "FUNCTION;",
  "FOR LOOP;",
  "IF STATEMENT;",
  "FOR...OF LOOP;",
  "IF ELSE;",
  "TERNARY;",
  "CONSOLE;",
  "RETURN;",
  "RECURSION;",
  "ARROW FUNCTION;",
  "METHOD;",
].map(i => i.split(""));


/**
* Graphics Setup
*/

// setup svg environment
let _svg;
try {
  _svg = setupD3();
} catch (e) {
  _svg = new d3.select("body").append("svg").attr("width", WIW).attr("height", WIH);
}

// a group that will hold the grasshopper and boxes for dizzymode
let space = _svg.append("g");

// create a group to store boxes
const boxes = space.append("g");

// create a group to store floating letters
const letters = space.append("g").style("user-select", "none");

// timer for game
let gameClock;

// color of the grasshopper logo
const ghopGreen = d3.hsl(120, 1, 0.725); //d3.rgb(115, 255, 115);

// object to save the score and the scoreboard text element
let Score = {
  board: _svg
    .append("text")
    .style("user-select", "none")
    .attr("alignment-baseline", "hanging")
    .attr("x", 0)
    .attr("y", 5)
    .attr("font-family", "impact")
    .attr("font-size", 50)
    .attr("fill", ghopGreen)
    .attr("stroke-width", 1)
    .attr("stroke", "black")
    .attr("font-weight", 900),

  value: 0,
};

// place to hold the current word being collected
let Word = {
  board: _svg
    .append("text")
    .style("user-select", "none")
    .attr("alignment-baseline", "baseline")
    .attr("x", 0)
    .attr("y", WIH - 10)
    .attr("font-family", "monospace")
    .attr("font-size", 30)
    .attr("fill", "white")
    .attr("stroke-width", 1)
    .attr("stroke", "black")
    .attr("font-weight", 900),

  list: wordList,

  goal: "JAVASCRIPT;".split(""),

  current: [],
};

// object that controls the boxes' color phasing
let Glitter = {

  glitterClock: null,

  on: (interval = 100, increment = 10) => {
    Glitter.off();
    Glitter.glitterClock = setInterval(() => Glitter.shiftBoxColor(increment), interval);
  },

  off: () => clearInterval(Glitter.glitterClock),

  shiftBoxColor: (increment = 10) => {
    for (let box of boxes.selectAll("rect").nodes().map(node => d3.select(node))) {
      // d3 attributes return strings, the + operator casts to a number
      let boxHue = +d3.hsl(box.attr("fill")).h;
      box.attr("fill", d3.hsl((boxHue + increment) % 360, 1, 0.5));
    }
  }
};

// create Grasshopper group = head + body
const grasshopper = space.append("g").attr("fill", ghopGreen);
grasshopper.append("path").attr("fill", ghopGreen).attr("d", "M 38.0575 7.865 H 36.5105 c 0 -1.7295 0.409 -3.416 1.1155 -4.9685 0.3255 -0.7155 -0.208 -1.5275 -0.994 -1.5275 a 1.093 1.093 0 0 0 -0.95 0.5545 A 11.988 11.988 0 0 0 34.0945 7.865 h 0 A 10.808 10.808 0 0 0 40.904 17.906 l 0.52 0.207 V 11.2315 A 3.3665 3.3665 0 0 0 38.0575 7.865 Z");
grasshopper.append("path").attr("fill", ghopGreen).attr("d", "M 25.06 4.625 16.95 1.369 c 0 2.6215 1.2145 5.5035 2.264 6.496 H 8.5555 c 1.025 5.6255 8.25 10.25 15.4075 10.25 h 0 L 20.53 21.55 c -0.7685 0.7685 -0.2245 2.0825 0.8625 2.0825 h 0 a 1.22 1.22 0 0 0 0.8625 -0.3575 L 27.4 18.131 l 0.0645 0.0265 v 4.2125 c 0 0.672 0.5255 1.25 1.197 1.2605 A 1.22 1.22 0 0 0 29.9 22.411 v -4.3 h 2.44 v 4.257 c 0 0.6715 0.5255 1.25 1.197 1.2605 a 1.22 1.22 0 0 0 1.243 -1.22 v -4.3 h 0 C 34.7825 11.9935 31.1 7.1 25.06 4.625 Z");

// bounding box of original grasshopper svg
const bb = grasshopper.node().getBBox();

// offset for grasshopper origin (top-left corner to center)
const offset = {
  x: bb.x + bb.width / 2,
  y: bb.y + bb.height / 2
};

// apply tolerance
const shrinkage = tolerance * 2;
bb.width -= shrinkage;
bb.height -= shrinkage;
bb.x += tolerance;
bb.y += tolerance;

// initial placement of grasshopper
grasshopper.attr("transform", `translate(${CX - offset.x}, ${y - offset.y}) rotate(${90 * Math.atan(dy)}, ${offset.x}, ${offset.y})`);

// distances from right side of screen
let grasshopperHead = (CX - (bb.width / 2));
let grasshopperTail = (CX + (bb.width / 2));

// laserGuideguide
let laserGuide = space
  .append("line")
  .lower()
  .attr("x1", (WIW + bb.width + shrinkage) / 2)
  .attr("y1", y);


/**
 * Move grasshopper
 */

// update laserGuide
function updateLaser() {
  laserGuide
    .attr("y1", y)
    .attr("x2", WIW - x + ((Score.value + 1) * holeTohole) + sideLength)
    .attr("y2", gaps[Score.value]);
}

// give the velocity a boost and change color
function jump() {
  dy = -0.20;
  grasshopper.selectAll("path").attr("fill", d3.hsl((ghopGreen.h + 5 * ++taps) % 360, 1, 0.725));
}

// will jump if the grasshopper is below the next hole
function autoJump() {
  let holeClearance = (gaps[Score.value] + sideLength * holeHeight / 2) - (y + bb.height / 2);
  let heightAboveFloor = WIH - (y + bb.height / 2);
  if (holeClearance < 10 || heightAboveFloor < 10) {
    jump();
  }
}

// calculate and draw the grasshopper's next orientation
function moveGrasshopper() {
  // double integreate to get position (fake to make game easier)
  dy = dy + ddy * dt;
  y = y + dy * dt + ddy * dt / 2;

  // move and rotate the graphics to the correct frame
  grasshopper.attr("transform", `translate(${CX - offset.x}, ${y - offset.y}) rotate(${90 * Math.atan(dy)}, ${offset.x}, ${offset.y})`);
}


/**
 * Move Boxes
 */

// generate a column of boxes with a hole centered at h, distance from the top of screen
function createBoxes(h) {
  let hue = (x / 12) % 360;
  for (let i = holeHeight / 2; i < holeHeight / 2 + colSize; ++i) {
    let boxColor = d3.hsl((hue += 10) % 360, 1, 0.5);
    boxes
      .append("rect")
      .lower()
      .attr("fill", boxColor)
      .attr("width", sideLength)
      .attr("height", sideLength)
      .attr("x", WIW)
      .attr("y", h + sideLength * i);
    boxes
      .append("rect")
      .lower()
      .attr("fill", boxColor)
      .attr("width", sideLength)
      .attr("height", sideLength)
      .attr("x", WIW)
      .attr("y", h - sideLength * (i + 1));
  }

  // create letter
  if (words) {
    if (Word.goal.length === 0) {
      Word.goal = wordList[0].slice();
      wordList.push(wordList.shift());
    }
    let nextLetter = Word.goal.shift();
    letters
      .append("text")
      .attr("font-family", "monospace")
      .attr("alignment-baseline", "middle")
      .attr("fill", "white")
      .attr("stroke-width", 1)
      .attr("stroke", "black")
      .attr("font-weight", 900)
      .text(nextLetter)
      .attr("y", h)
      .attr("x", WIW + sideLength)
      .attr("font-size", 60);
  }
}

// move all the boxes to the left
function moveBoxes() {
  for (let box of boxes.selectAll("rect").nodes().map(node => d3.select(node))) {
    // d3 attributes return strings, the + operator casts to a number
    box.attr("x", +box.attr("x") - 1);
    if (+box.attr("x") + sideLength < 0) {
      box.remove();
    }
  }

  // move letters to the left
  if (words) {
    for (let letter of letters.selectAll("text").nodes().map(node => d3.select(node))) {
      // d3 attributes return strings, the + operator casts to a numbers
      letter.attr("x", +letter.attr("x") - 1);
      if (+letter.attr("x") < CX) {
        Word.current.push(letter.text());
        Word.board.text(Word.current.join(""));
        if (letter.text() === ";") {
          Word.current = [];
        }
        letter.remove();
      }
    }
  }
}

/**
* Game Functions
*/

// resets the game
function reset() {
  Glitter.off();
  x = 0;
  y = CY;
  dy = 0;
  taps = 0;
  Score.value = 0;
  gaps = [];
  boxes.selectAll("*").remove();
  Score.board
    .text("")
    .attr("x", 0)
    .attr("font-size", 50)
    .on(eventType, null);
  init();
  letters.selectAll("text").remove();
  Word.current = [];
  Word.board.text("");
  Word.goal = "JAVASCRIPT;".split("");
}

// check if the grasshopper has collided with the ceiling, floor, or a box
function checkCollisions() {
  // max height at ceiling
  if (ceiling && y < 0) {
    y = 0;
  }

  // if it hits the floor, stop movement and stop the game clock
  if (floor && y > WIH) {
    dy = 0;
    y = WIH;
    if (collisions) {
      gameEnd();
    }
  }

  // distances from right side of screen
  let columnStart = (x - ((Score.value + 1) * holeTohole));
  let columnEnd = (x - (Score.value + 1) * holeTohole - sideLength);

  if (collisions) {
    // nose entered column
    if (columnStart > grasshopperHead) {
      let yToholeH = Math.abs(y - gaps[Score.value]);
      // check if collided with a column
      if ((yToholeH + bb.height / 2) > (sideLength * holeHeight / 2)) {
        gameEnd();
      }
    }
  }

  // tail exited column
  if (columnEnd > grasshopperTail) {
    Score.value++;
    Score.board.text(binary ? `0b${Score.value.toString(2)}` : Score.value);
  }
}

// triggered to run when the game ends
function gameEnd() {
  cancelAnimationFrame(gameClock);

  // grasshopper bounces down to the floor
  grasshopper
    .transition()
    .duration(1000 + WIH - y)
    .ease(d3.easeBounce)
    .attr("transform", `translate(${CX - offset.x}, ${WIH - (bb.height + shrinkage)})`);

  // check if score is zero
  if (Score.value === 0) {
    Score.board
      .text("Play Again")
      .attr("x", 2)
      .on(eventType, reset);
  }

  // expand score
  let scoreBB = Score.board.node().getBBox();
  Score.board
    .transition()
    .ease(d3.easeElastic)
    .duration(500)
    .attr("transform", `scale(${Math.min(WIH / scoreBB.height, WIW / scoreBB.width) * 0.95})`);

  // make boxes change color
  Glitter.on();
}


// main game loop
function update() {
  gameClock = requestAnimationFrame(update);

  // increment distance traveled
  x++;

  // rotate boxes and grasshopper
  if (dizzyMode) {
    space.attr("transform", `rotate(${x * 0.5}, ${CX}, ${CY})`);
  }

  // create new boxes
  if (x % holeTohole === 0) {
    let yHole = 2 * sideLength + Math.random() * (WIH - 4 * sideLength);
    createBoxes(yHole);
    gaps.push(yHole);
  }

  // move the grasshopper
  moveGrasshopper();

  // move boxes and remove any offscreen
  moveBoxes();

  // check for collisions
  checkCollisions();

  // autopilot
  if (autopilot) {
    autoJump();
  }

  // move laser
  if (laser) {
    updateLaser();
  }
}

// initialize game
function init() {
  if (changeColor) {

    // reassign listener to jump()
    _svg.on(eventType, autopilot ? null : jump);
    jump();

    // remove floor and ceiling, make boxes color pulse, increase the column height
    if (dizzyMode) {
      floor = false;
      ceiling = false;
      Glitter.on(120, -10);
      colSize = 10;
    }

    // red line
    if (laser) {
      laserGuide
        .attr("stroke", "red")
        .attr("stroke-width", 2);
    }

    // armor
    if (autopilot) {
      grasshopper
        .attr("stroke", "silver")
        .attr("stroke-width", 2);
    }

    // make font smaller if binary mode
    if (binary) {
      Score.board.attr("font-size", 30);
    }

    requestAnimationFrame(update);
  }
}

// wait for the first tap, disable other listener (needed for desktop vs. mobile. "click" doesn't work on iPhone)
_svg.on("touchstart", () => {
  _svg.on("mousedown", null);
  eventType = "touchstart";
  init();
});

_svg.on("mousedown", () => {
  _svg.on("touchstart", null);
  eventType = "mousedown";
  init();
});
5 Likes

2 Likes

2 Likes

Great work! (It’s hard!)

1 Like

another one

2 Likes

82! That’s about twice my high score!

1 Like

2 Likes

Hehehe yea , its an easy game.

1 Like

2 Likes

I’m dizzy right now bro
No helping here.
Oh WHAT?!?

1 Like

3 Likes

213! That’s the highest I’ve seen yet!

1 Like

2 Likes

Well done! Now try it in dizzy mode…

Screenshot_20190408-145705

2 Likes

image

2 Likes

Hoppy%20Grace

Why isn’t ‘Hoppy Grace’ Marked as done?

3 Likes

Screenshot_20190406-201823

2 Likes

Screenshot_20190406-112310_Grasshopper

2 Likes

Hey there, nice score!

Hoppy Grace unfortunately can’t be marked as done, as it doesn’t technically have an end!

In our office, we have a game of Hoppy Grace that has been running on autopilot for 3 weeks! The score is 834,849, as of this writing.

The app marks a puzzle as complete if the “completion” event has been triggered, and this event then prompts the app to show the congratulations message and “Next Lesson” button. However, this event would interfere with the game, so the event never triggers for this lesson.

I’ll work on getting a screenshot posted of the 3-week game!

1 Like