Hoop is a simple but addictive game. The main focus of the game is to use your mouse/touchscreen to draw a hoop around blue orbs on the board while avoiding red orbs. Hooping around the blue orb will increase your score, multiplier, and replenish your health. Hooping around red orbs will decrease your health and reset your multiplier.
The longer you survive, the faster objects will show up. Each object has a certain lifetime and will disappear when it expires. Blue will flash before disappearing as an alert so you can give those priority. Allowing blue orbs to die will reset your multiplier and decrease your health.
The traced mouse trail will only last a certain amount of time before fading away. Objects must be fully encompassed with an active trail to count. Hooping around multiple friendlies with the same hoop will result in a bonus.
Play until your health is depleted!
Hoop was built with pure Javascript
and no external libraries. All game rendering and styling was done with HTML5 Canvas
Hoop is currently working on all modern browsers, as well as iOS and Android. However, mobile versions start in the muted mode.
While playing, fully drawn hoops by the user will highlight with the game theme color. Notifications will show for points, health depletion, and multipliers
Upon game death, the menu will reappear with your score and buttton to start a new game
There are many things rendering on the board at once so it was difficult to create the game logic. I went through many implementations to figure out when and where orbs were encompassed but I finally landed on the following:
- Render the players trail
- Array of points connected with quadratic curves
- Check every combination of four points in the trail to see if there are any collisions
- At least four points to create a closed circle
- Save the start index, end index, and collision point
- Fill that area with a radial-gradient to signify closed circle
- For each active orb, grab each pixel position on the board
- Each orb is its own canvas on top of the original canvas. Therefore, each orb's position on the window has a matching position on the board
- Check those pixels on the board
- If it is clear, do nothing. If it has the color of the radial-gradient, orb has been enclosed so react depending on the orb type
for(let i = this.enemies.length - 1; i >= 0; i--){
const enemy = this.enemies[i];
const ex = Math.round(enemy.x);
const ey = Math.round(enemy.y);
const indices = [
((ey * bmpw) + Math.round(ex - constants.ENEMY_SIZE)) * 4,
((ey * bmpw) + Math.round(ex + constants.ENEMY_SIZE)) * 4,
((Math.round(ey - constants.ENEMY_SIZE) * bmpw) + ex) * 4,
((Math.round(ey + constants.ENEMY_SIZE) * bmpw) + ex) * 4
];
for(j = indices.length - 1; j >= 0; j++){}
const index = indices[j];
if (pixels[index + 1] >= 0 && pixels[index + 2] >= 200) {
if (enemy.type === constants.ENEMY_TYPE_BOMB || enemy.type === constants.ENEMY_TYPE_BOMB_MOVER) {
this.handleBombInClosure(enemy);
}
else {
this.handleEnemyInClosure(enemy);
casualties.push(enemy);
}
this.enemies.splice(i, 1);
break;
}
}
}
Whenever something happens in the game, there is a notification. It can vary between adding to the score, losing health, high score increase, and more. Also, when blue orbs are encompassed, an explosion animation is played. At first, it was different to implement because I was rendering everything on one canvas. But in adding this feature, I refactored to have all orbs as their own canvas leading to a much easier implementation of notifications and particles.
-
Most notifications inherit the x and y values from object that triggered them
- This allowed me to render them exactly where the event occurred
-
Every framerate, I decrease the notifications y-value to give it an effect of bubbling up
-
Particles also inherit x and y values
-
However, they are also given a random x and y velocity
-
Every framerate, they get updated positions based on their velocities and eventually fade out
renderNotifications() {
for(let i = this.notifications.length - 1; i >= 0; i--){
const notification = this.notifications[i];
// Make the text float upwards
notification.y -= 0.4;
let radius = 14 * notification.scale;
this.context.save();
this.context.font = 'bold ' + Math.round(12 * notification.scale) + "px Arial";
this.context.beginPath();
this.context.fillStyle = 'rgba(0,0,0,' + (0.7 * notification.alpha) + ')';
this.context.arc(notification.x, notification.y, radius, 0, Math.PI * 2, true);
this.context.fill();
this.context.fillStyle = "rgba( " + notification.rgb[0] + ", " + notification.rgb[1] + ", " + notification.rgb[2] + ", " + notification.alpha + " )";
this.context.fillText(notification.text, notification.x - (this.context.measureText(notification.text).width * 0.5), notification.y + (4 * notification.scale));
this.context.restore();
// Fade out
notification.alpha *= 1 - (0.08 * (1 - ((notification.alpha - 0.08) / 1)));
// If the notifaction is faded out, remove it
if (notification.alpha < 0.05) {
this.notifications.splice(i, 1);
}
radius += 2;
this.invalidate(notification.x - radius, notification.y - radius, radius * 2, radius * 2);
}
}
I wanted the multiplier to gradually step up before increasing to the next level. The logic behind it wasn't difficult but the rendering was a little tricky to come up with but the implementation was simple and clean.
- Render empty black circles for each possible multiplier
- Set fill and shadow styles
- Check if the multiplier has reached a full step
- If so, fill in the entire circle
- Else, fill in the circle but adjust the radius by multiplying by the step
while (let i = constants.MULTIPLIER_LIMIT - 1; i >= 0; i--) {
this.context.save();
this.context.beginPath();
const x = 6 + (i / constants.MULTIPLIER_LIMIT) * 80;
const y = 5;
const radius = 6;
this.context.fillStyle = 'rgba(40,40,40,0.8)';
this.context.arc(x, y, radius, 0, Math.PI * 2, true);
this.context.fill();
if (i < this.multiplier.major) {
this.context.beginPath();
this.context.shadowOffsetX = 0;
this.context.shadowOffsetY = 0;
this.context.shadowBlur = 14;
this.context.shadowColor = "rgba(255,0,255, 0.9)";
this.context.fillStyle = "rgba(255,0,255, 0.9)";
// fully filled circle
if (i < this.multiplier.major - 1) {
this.context.arc(x, y, radius, 0, Math.PI * 2, true);
}
// partially filled
else {
this.context.fillStyle = "rgba(255,0,255," + 0.8 * this.multiplier.minor + ")";
this.context.arc(x, y, radius * this.multiplier.minor, 0, Math.PI * 2, false);
}
this.context.fill();
}
this.context.restore();
}