Updated

Hammer Time

The Hammer Time extension window showing two steel buttons depicting a hammer and a recycling bin on a cartoonish construction background

Hammer Time was a project thrown together by myself and one other developer in less than 48 hours to win the Codesmith PTRI 9 hackathon. With this extension, you can smash up webpages using a hammer. It's a great way to blow off steam or just pass the time.

When you click the hammer in the extension window, it becomes your cursor. Smashing elements on the page will create spiderweb-like cracks in the page, as if it were made of glass. This will also produce glass breaking sounds, generate particle effects from each smash, and cause elements to "fall off" the page if they are struck enough times. You can then either put the hammer back and continue on browsing as usual, or click the recycling bin to trigger a page refresh and clean up your mess.

The app's core functionality that handles all of this user interaction is less than 200 line of code. Most of it is done through simple DOM manipulation and CSS style properties. Here's a detailed explanation of each interaction and the simple code behind what drives it.

Glass Effect

Upon clicking an element on the page with the hammer, the element seems to "shatter" as a spiderweb broken glass effect appears centered at the point of the click and the element seems to be nudged out of place slightly. This is all handled within the `createBrokenGlassEffect` function, which takes the event from the click and the target element that was clicked by the user.

The first thing the function does is call another function to trigger the sound (which I will discuss later) and identify the exact coordinates of the user's click.

const createBrokenGlassEffect = (event, element) => {
    playRandomSound();
    const rect = element.getBoundingClientRect();

    const xPos = event.clientX - rect.left;
    const yPos = event.clientY - rect.top;

Then, one of four different transparent spiderweb crack images is selected from the runtime image files and is added to the DOM. I use a template string to set a bunch of default CSS properties on this glass image element, including randomized rotation and scale.

    const imgElement = document.createElement('img');

    const randomImage = Math.floor(Math.random() * 4) + 1;
    imgElement.src = chrome.runtime.getURL(
      `./images/broken_glass_0${randomImage}.png`
    );

    const randomRot = Math.floor(Math.random() * 360);
    const randomScale = 1 + Math.random() * 2;

    imgElement.setAttribute(
      'style',
      `
      unset: all;
      width: 100px;
      height: 100px;
      position: absolute;
      top: ${yPos}px;
      left: ${xPos}px;
      z-index: 1000;
      background-color: transparent;
      border: 0px solid transparent !important;
      transform: translate(-50%, -50%) rotate(${randomRot}deg) scale(${randomScale});
      pointer-events: none;
      filter: none !important;
    `
    );

Lastly, the image is added to the element that was clicked as a child, and the overflow: hidden property is set so that the glass cracks don't extend beyond the edge of the element.

    element.appendChild(imgElement);
    element.style.overflow = 'hidden';

In addition to making the cracks appear, the element is "nudged" out of place upon being smashed. This is handled by adding a small amount of translation and rotation to the element, along with some transition properties for a little bit of animation. A slight box shadow is added to actually make the element appear as though it is a piece of glass.

  const nudge = (element) => {
    element.style.transition = `transform 50ms cubic-bezier(0.1,-0.93, 1, 2.33)`;
    const randomX = Math.floor(Math.random() * 4) - 2;
    const randomY = Math.floor(Math.random() * 4) - 2;
    const randomRot = Math.floor(Math.random() * 4) - 2;
    element.style.boxShadow = `rgba(149, 157, 165, 0.2) 0px 8px 24px`;
    element.style.transform = `translate(${randomX}px, ${randomY}px) rotate(${randomRot}deg)`;
  };

Falling Pieces

If you smash a given element enough times (four, to be precise) it will "fall" off the page. The first part of this is determining whether an element has been hit four times. It all starts with this initially-empty object:

const elementHits = {};

This stores key-value pairs where the key is the element that was hit and the value is the number of times it was hit. The challenge with this is using a reasonable value for the key that would be unique and easily identify the element - the obvious choice would be the "id" attribute, but not all elements have an id attribute. So if they don't, I add a random one. This should be sufficiently unique so as to never collide on a single webpage.

  const demolishOnClick = (event) => {
    event.preventDefault();
    const element = event.target;
    if (!element.id) {
      element.setAttribute('id', Math.random().toString());
    }

    elementHits[element.id] = ++elementHits[element.id] || 1;

    createBrokenGlassEffect(event, element);
    if (elementHits[element.id] > 3) {
      fall(element);
    } else {
      nudge(element);
    }
  };

The actually simulate the element falling, I couldn't just remove it from the DOM - that would cause all the other elements around it to shift and fill its place. So I needed to reserve its original position but make it seem like it was falling away forever.

I did this by using the transform property again - but also setting the element's opacity to zero and setting cursor events to none. This means you can no longer see it and you can no longer click it - but it's actually still there.

I also used a transition with a cubic-bezier easing function that would cause the element to accelerate downward. I moved it down 100% of the view height with a 900ms transition, and set the opacity to 0 after 1200ms. For a little extra fun, I added some random 3D rotation to the element as well.

  const fall = (element) => {
    element.style.transition = `transform 900ms cubic-bezier( 0.78, -0.02, 0.76, 0.3 )`;
    const randomRot = Math.floor(Math.random() * 360);
    element.style.transform = `translateY(100vh) rotate3d(${Math.random()}, ${Math.random()}, ${Math.random()}, ${randomRot}deg)`;
    element.style.pointerEvents = 'none';
    setTimeout(() => (element.style.opacity = 0), 1200);
  };

Particle Effects

When you click to smash an element, you may notice a bunch of tiny little pieces of glass shooting off in every direction. These are nothing more than just a few small div elements with a gray background and random animations.

Inside the createBrokenGlassEffect function, after creating the broken glass transparent png, this loop is called to invoke the function createParticle 40 times.

    for (let i = 0; i < 40; i++) {
      createParticle(event.clientX, event.clientY);
    }

As I mentioned, a "particle" is just a div (although I gave it a custom element name of "particle"), with a few random CSS properties set on it (in retrospect, this would have looked better as a template string). It's given a random size of 1px by 1-10px, and a random grayscale color.

const createParticle = (xPos, yPos) => {
    const particle = document.createElement('particle');
    particle.style.left = 0;
    particle.style.top = 0;
    particle.style.position = 'fixed';
    particle.style.pointerEvents = 'none';
    document.body.appendChild(particle);
    const size = Math.floor(Math.random() * 9) + 1;
    particle.style.width = `${size}px`;
    particle.style.height = `1px`;
    particle.style.zIndex = '10000';

    particle.style.backgroundColor = `hsl(0, 0%, ${Math.floor(
      Math.random() * 100
    )}%)`;

I then used the DOM Animations API to create the keyframes for each individual particle. Each particle starts with its transform within a few pixels of the clicked position, and finishes in a random distance, direction, and rotation of that. The duration of the animation is also randomized between 500ms and 1500ms, and after the animation completes the particle is removed from the DOM.

    const destX = xPos + (Math.random() - 0.5) * 350;
    const destY = yPos + (Math.random() - 0.5) * 350;

    const randomRot = Math.floor(Math.random() * 360);

    const animation = particle.animate(
      [
        {
          transform: `translate(${xPos - size / 2}px, ${
            yPos - size / 2
          }px) rotate(0deg)`,
          opacity: 1,
        },
        {
          transform: `translate(${destX}px, ${destY}px) rotate(${randomRot}deg)`,
          opacity: 0,
        },
      ],
      {
        duration: 500 + Math.random() * 1000,
        easing: 'cubic-bezier( 0.17, 0.8, 0.43, 0.92 )',
      }
    );
    animation.onfinish = () => particle.remove();
  };

Cursor

When you are holding the hammer, it becomes your cursor on the page. When you click, the hammer appears to animate downwards to smash the page. Setting the cursor to the hammer and having it animate when clicking was actually not as difficult as I thought it might be (by animate, I really just mean changing the cursor based on the two states - so a two-frame animation). I simply defined these two functions which globally set the cursor property on every element in the DOM:

const customCursorStyleElement = document.createElement('style');

const hammerDown = () => {
    customCursorStyleElement.innerHTML = `*, *::before, *::after {
    cursor: url('${chrome.runtime.getURL(
      `images/hammer_cursor_hit.png`
    )}') 9 58, auto !important;
    }`;
  };

  const hammerUp = () => {
    customCursorStyleElement.innerHTML = `*, *::before, *::after {
    cursor: url('${chrome.runtime.getURL(
      `images/hammer_cursor.png`
    )}') 9 58, auto !important;
    }`;
  };

Then I initialize with the hammerUp() function and trigger the hammerDown() function followed by the hammerUp() function again 25ms later, every time the user clicks a DOM element.

  hammerUp();

  const animateCursor = () => {
    hammerDown();
    setTimeout(hammerUp, 25);
  };

  document.addEventListener('mousedown', animateCursor);

  document.head.appendChild(customCursorStyleElement);

Sound

The last addition I made was to trigger a glass-breaking sound whenever an element was smashed. Each click is met with a random sound from one of eight different sound effects I found for free online.

Whenever the hammer is removed from the extension tray to be used, my code automatically adds eight new elements to the body of the DOM. These elements are audio elements, each pointing towards a different mp3 file in the extension's runtime.

  const createAudioElements = () => {
    const elements = [];
    for (let i = 1; i <= 8; i++) {
      const src = chrome.runtime.getURL(`sounds/glass_0${i}.mp3`);
      const element = document.createElement('audio');
      element.src = src;
      element.volume = 0.1;
      document.body.appendChild(element);
      elements.push(element);
    }
    return elements;
  };

The elements returned by this function are then stored and used later anytime a sound needs to be played. The function playRandomSound() chooses a random element from these eight elements and invokes the play() function on that element.

  const audioElements = createAudioElements();

  const playRandomSound = () => {
    const randomIndex = Math.floor(Math.random() * audioElements.length);
    audioElements[randomIndex].play();
  };

Cleanup

Arguably the most challenging part of this project for me is the part that you, as the user, probably wouldn't even think about - and that's the "cleanUp" functionality that occurs when you put the hammer away and continue browsing. The reason that this was so challenging is that persisting certain information between different hammer time "sessions" is not as straightforward as you would think. The Chrome Extension runtime makes it very challenging to keep track of variables after the script has finished executing.

The only solution that I tried that ended up working is using the dreaded old var keyword to define a function that could be invoked in different runtimes. To better explain the challenge, here is code from the extension's "background.js" file that is declared in the manifest. It receives messages from the popup clicked by the user and tracks a state variable destructionMode that will cause one of two scripts to be triggered. The first is "content.js" which contains all the code I just showed you, and the other is just a function argument that invokes the function cleanUp if it exists.

    if (message === 'togglestate') {
      destructionMode = !destructionMode;

      if (destructionMode) {
        chrome.scripting.executeScript({
          target: { tabId: tab.id },
          files: ['content.js'],
        });
      } else {
        chrome.scripting.executeScript({
          target: { tabId: tab.id },
          func: () => {
            try {
              cleanUp && cleanUp();
            } catch (error) {
              console.log(error);
            }
          },
        });
      }
    }

The cleanUp function is actually defined with the var keyword inside "content.js", and it handles removing all event listeners from the DOM and the added audio and style elements. This enables the user to go back to browsing a broken page and using the elements (that still exist) as they were intended to be used.

  var cleanUp;
  
  ...
  
  cleanUp = () => {
    document.removeEventListener('click', demolishOnClick);
    document.removeEventListener('mousedown', animateCursor);
    customCursorStyleElement.remove();
    audioElements.forEach((el) => el.remove());
  };

Popup and Background Communication

The only other element of this extension that might be worth mentioning is the communication between the popup tray and the "background.js" file itself.

The HammerTime popup menu

The popup is fairly simple HTML, CSS, and JS. The important part of it, the "popup.js" file, handles sending messages to the "background.js" file in order to execute the relevant scripts. It also handles tracking the state of the "background.js" file which shows/hides the hammer (making the user feel like they picked it up).

let destroymode = false;
const hammerElement = document.querySelector('#toggle img');

const updateUI = () => {
  if (destroymode) {
    hammerElement.style.opacity = 0;
  } else {
    hammerElement.style.opacity = 1;
  }
};

updateState = () => {
  chrome.runtime.sendMessage({ message: 'getstate' }, (response) => {
    destroymode = response?.state ?? false;
    updateUI();
  });
};

function toggleDestroyMode() {
  destroymode = !destroymode;
  updateUI();
  chrome.runtime.sendMessage({ message: 'togglestate' });

  if (destroymode) setTimeout(() => window.close(), 250);
}

function activateCleanUp() {
  chrome.runtime.sendMessage({ message: 'refresh' });
  setTimeout(() => window.close(), 150);
}

updateState();

const toggle = document.querySelector('#toggle');
toggle.addEventListener('click', toggleDestroyMode);

const cleanup = document.querySelector('#cleanup');
cleanup.addEventListener('click', activateCleanUp);

As you can see, there are three possible messages that "popup.js" can potentially send to the "background.js" script - 'togglestate', 'getstate', and 'refresh'. The "popup.js" script can also react to a response returned by the "background.js" file, in this case the updated state variable destroymode returned from the 'getstate' message, and update its own internal state accordingly.

The "background.js" file can just listen for these messages and invoke the appropriate functionality with a series of conditional statements.

chrome.runtime.onMessage.addListener(async function (
    { message },
    _sender,
    sendResponse
  ) {
    if (message === 'getstate') {
      sendResponse({ state: destructionMode });
    }

    const tab = await getCurrentTab();

    if (message === 'togglestate') {
      destructionMode = !destructionMode;

      if (destructionMode) {
        chrome.scripting.executeScript({
          target: { tabId: tab.id },
          files: ['content.js'],
        });
      } else {
        chrome.scripting.executeScript({
          target: { tabId: tab.id },
          func: () => {
            try {
              cleanUp && cleanUp();
            } catch (error) {
              console.log(error);
            }
          },
        });
      }
    }
    if (message === 'refresh') {
      destructionMode = false;
      chrome.scripting.executeScript({
        target: { tabId: tab.id },
        func: () => location.reload(),
      });
    }
  });

And that's pretty much the entire application! As you can tell from the code, it's a little rough around the edges - but I do like to point out that it was developed in less than 48 hours (and not in the "stayed up all night" sense - that actual development time spent on this was probably more around 7 or 8 hours).

It was a really fun project, and I'm proud that it won the hackathon against 18 other teams.