Inspiration

At the beginning we started defining the prompt, our thoughts were mostly about what would help someone learn while having fun. From that we thought of two main ideas of a handwriting aid that's one continuous motion of writing letters, and a piano dueling game that was similar to horse in order to build playing speed and comfort in recognizing notes. Then for the theme we decided on wizards because we came to Hacklahoma dressed as wizards and we wanted to incorporate it.

What it does

When you go on the website it'll give you an option to create a game, then after pressing it'll give you a magic link to send to a friend. After your friend clicks it a game will automatically start, Player 1 can practice for a bit or just immediately press start recording and then play a melody of 10 notes or less. After Player 1 records their melody, Player 2 then listens to notes by Player 1 alongside a (musical)stave and the corresponding keys lighting up. Once they listen to the melody for the first time they have the option to either relisten or submit their attempt after playing. If the attempt matches(with some tolerance) then a sound effect plays, otherwise Player 2 gets a letter added that spells MAGIC. Then repeat reversing the roles until a player has all 5 letters of the word MAGIC, that player loses and you get the option to start a new game.

How we built it

For this project we used a DOM interface and a full stack approach, we first split the project into two parts, client and server side. The server side is to make multiplayer rooms and handle player to player interaction. In order to make this we used a WebSocketServer to handle the interactions without players being able to interfere, like so:

const rooms = new Map();

const server = createServer();
const wss = new WebSocketServer({ server });

Once we had the server setup we needed to make helper methods to make "refereeing" easier:

function compareNotes(original, attempt) {
  // Generous comparison: allow ~300ms timing variance, ±1 extra/missing notes
  if (Math.abs(original.length - attempt.length) > 1) {
    return false;
  }

  const minLen = Math.min(original.length, attempt.length);
  let mismatches = 0;

  for (let i = 0; i < minLen; i++) {
    const o = original[i];
    const a = attempt[i];

    // Check note match
    if (o.note !== a.note) {
      mismatches++;
      if (mismatches > 1) return false;
      continue;
    }

    // Check timing (relative to first note)
    const oTime = o.timestamp - original[0].timestamp;
    const aTime = a.timestamp - attempt[0].timestamp;
    if (Math.abs(oTime - aTime) > 300) {
      mismatches++;
      if (mismatches > 1) return false;
    }
  }

  return true;
}

function getNextLetter(currentLetters) {
  const MAGE = 'MAGE';
  return MAGE[currentLetters.length] || '';
}

Then with these helper methods we used them to handle room management, safely send and broadcast WebSocket messages, compare recorded melodies with timing tolerance, and control turn-based game logic. Finally with all the outcomes defined we send it over to the users using this switch case to handle it:

wss.on("connection", (ws) => {
  let playerRoomId = null;
  let playerIndex = null;

  ws.on("message", (data) => {
    let msg;
    try {
      msg = JSON.parse(data);
    } catch {
      return;
    }

    switch (msg.type) {
      case "create-room": {
        const roomId = generateRoomId();
        rooms.set(roomId, {
          players: [{ ws, name: msg.name || "Player 1" }],
          currentTurn: 0,
          melody: null,
          letters: ["", ""],
          phase: "waiting", // waiting, recording, replaying
        });
        playerRoomId = roomId;
        playerIndex = 0;
        sendTo(ws, { type: "room-created", roomId, playerIndex: 0 });
        break;
      }

      case "join-room": {
        const room = rooms.get(msg.roomId);
        if (!room) {
          sendTo(ws, { type: "error", message: "Room not found" });
          return;
        }
        if (room.players.length >= 2) {
          sendTo(ws, { type: "error", message: "Room is full" });
          return;
        }
        room.players.push({ ws, name: msg.name || "Player 2" });
        playerRoomId = msg.roomId;
        playerIndex = 1;

        sendTo(ws, {
          type: "room-joined",
          roomId: msg.roomId,
          playerIndex: 1,
          opponentName: room.players[0].name,
        });

        // Notify player 1 that player 2 joined
        sendTo(room.players[0].ws, {
          type: "opponent-joined",
          opponentName: room.players[1].name,
        });

        // Start the game - player 0 records first
        room.phase = "recording";
        broadcast(room, {
          type: "game-start",
          currentTurn: 0,
          letters: room.letters,
        });
        break;
      }

      case "melody-submit": {
        const room = rooms.get(playerRoomId);
        if (!room || room.currentTurn !== playerIndex) return;

        room.melody = msg.notes;
        room.phase = "replaying";

        // Send melody to opponent for playback
        const opponentIndex = playerIndex === 0 ? 1 : 0;
        sendTo(room.players[opponentIndex].ws, {
          type: "melody-received",
          notes: msg.notes,
        });
        break;
      }

      case "attempt-submit": {
        const room = rooms.get(playerRoomId);
        if (!room || room.currentTurn === playerIndex) return;

        const success = compareNotes(room.melody, msg.notes);

        if (!success) {
          // Add letter to the player who failed
          room.letters[playerIndex] += getNextLetter(room.letters[playerIndex]);
        }

        // Check for game over
        if (room.letters[playerIndex].length >= 5) {
          broadcast(room, {
            type: "game-over",
            loser: playerIndex,
            letters: room.letters,
          });
          room.phase = "ended";
          return;
        }

        // Switch turns - the one who just attempted now records
        room.currentTurn = playerIndex;
        room.melody = null;
        room.phase = "recording";

        broadcast(room, {
          type: "turn-result",
          success,
          letters: room.letters,
          currentTurn: room.currentTurn,
        });
        break;
      }

      case "new-game": {
        const room = rooms.get(playerRoomId);
        if (!room) return;

        room.letters = ["", ""];
        room.currentTurn = 0;
        room.melody = null;
        room.phase = "recording";

        broadcast(room, {
          type: "game-start",
          currentTurn: 0,
          letters: room.letters,
        });
        break;
      }

      case "forfeit": {
        const room = rooms.get(playerRoomId);
        if (!room) return;

        // Player who forfeits loses
        broadcast(room, {
          type: "game-over",
          loser: playerIndex,
          letters: room.letters,
        });
        room.phase = "ended";
        break;
      }
    }
  });

  ws.on("close", () => {
    if (playerRoomId) {
      const room = rooms.get(playerRoomId);
      if (room) {
        broadcast(room, { type: "opponent-disconnected" }, ws);
        rooms.delete(playerRoomId);
      }
    }
  });
});

with this switch case every player can create a room for them and one other player without overlap and rooms staying in memory after closing. It also handles some options of the game that would affect both players(disconnect, forfeit, game over, etc.) since the client side can't handle that. Moving on to the client side where we have all changing UI elements controlled by main.js and have most static things in index.html. The first thing we needed was the Piano which is a simple button holding data-note, then to play the note we use a function play note to go to the corresponding audio file:

index.html:
<div id="piano">
          <div id="black-keys">
            <button data-note="C#3">W</button>
            <button data-note="D#3">E</button>
            <span class="spacer"></span>
            <button data-note="F#3">T</button>
            <button data-note="G#3">Y</button>
            <button data-note="A#3">U</button>
            <span class="spacer"></span>
            <button data-note="C#4">O</button>
            <button data-note="D#4">P</button>
            <span class="spacer"></span>
            <button data-note="F#4">[</button>
            <button data-note="G#4">]</button>
            <button data-note="A#4">C</button>
          </div>
          <div id="white-keys">
            <button data-note="C3">A</button>
            <button data-note="D3">S</button>
            <button data-note="E3">D</button>
            <button data-note="F3">F</button>
            <button data-note="G3">G</button>
            <button data-note="A3">H</button>
            <button data-note="B3">J</button>
            <button data-note="C4">K</button>
            <button data-note="D4">L</button>
            <button data-note="E4">;</button>
            <button data-note="F4">'</button>
            <button data-note="G4">Z</button>
            <button data-note="A4">X</button>
            <button data-note="B4">V</button>
            <button data-note="C5">B</button>
          </div>
        </div>
      </div>
main.js:
NOTE_PATHS = {"map of notes to audio paths"} 
function playSound(note) {
  if (!NOTE_PATHS[note]) return;
  const audio = new Audio(NOTE_PATHS[note]);
  audio.play().catch(() => {});
}

We also had to map all the keyboard and midi input to notes, then to make them actually play we added event listeners for them. To track the time length of the notes we just used Date.now - startTime at note end, defining startTime at note start. We used this function in order to quantize how long each note was:

function quantizeDuration(ms) {
  if (ms <= 187) return "16"; // sixteenth
  if (ms <= 375) return "8"; // eighth
  if (ms <= 750) return "q"; // quarter
  if (ms <= 1500) return "h"; // half
  return "w"; // whole
}

With the note data saved when a melody/attempt is recorded we translate that to stave style with quantizeDuration and this function:


function renderNotesOnStave(context, stave, notesData, clef) {
  const staveNotes = notesData.map((nd) => {
    const note = new StaveNote({
      keys: nd.keys,
      duration: nd.duration,
      clef: clef,
    });

    // Add accidentals for sharps (supports multiple keys in chords)
    if (nd.accidentalIndices && nd.accidentalIndices.length > 0) {
      nd.accidentalIndices.forEach((idx) => {
        note.addModifier(new Accidental("#"), idx);
      });
    }

    return note;
  });

For some of our extra bits like showing the hands of the wizard when listening to melody we did some css opacity changing. For the workflow of what functions are called when, we used a similar switch case as in the server side:

function handleMessage(msg) {
  switch (msg.type) {
    case "room-created":
      roomId = msg.roomId;
      playerIndex = msg.playerIndex;
      roomLinkInput.value = `${location.origin}/${roomId}`;
      shareLink.style.height = "96px";
      shareLink.style.padding = "4px";
      break;

    case "room-joined":
      roomId = msg.roomId;
      playerIndex = msg.playerIndex;
      lobby.style.display = "none";
      break;

    case "opponent-joined":
      lobby.style.display = "none";
      break;

    case "game-start":
      gameContainer.style.display = "flex";
      gameOver.style.display = "none";
      updateLetters(msg.letters);
      initStaff(); // Initialize empty staff

      if (msg.currentTurn === playerIndex) {
        turnInfo.textContent = "Your turn to record a melody!";
        hideAllControls();
        setupRecordingMode();
      } else {
        turnInfo.textContent = "Opponent is recording a melody...";
        hideAllControls();
      }
      showMessage("");
      break;

    case "melody-received":
      receivedMelody = msg.notes;
      turnInfo.textContent = "Listen to the melody, then try to replay it!";
      hideAllControls();
      listenBtn.style.display = "inline-block";
      showMessage("");
      renderStaff([]); // Clear staff before listening
      break;

    case "turn-result":
      stopTimer();
      updateLetters(msg.letters);

      const wasMyAttempt = msg.currentTurn === playerIndex;
      if (wasMyAttempt) {
        // I just attempted, now I record
        showMessage(
          msg.success ? "Nice! You matched it!" : "Oops! You got a letter.",
        );
        turnInfo.textContent = "Your turn to record a melody!";
        hideAllControls();
        setupRecordingMode();
      } else {
        // Opponent just attempted, now they record
        showMessage(
          msg.success
            ? "Opponent matched your melody!"
            : "Opponent failed! They got a letter.",
        );
        turnInfo.textContent = "Opponent is recording a melody...";
        hideAllControls();
        renderStaff([]); // Clear staff while waiting
      }
      break;

    case "game-over":
      stopTimer();
      gameContainer.style.display = "none";
      gameOver.style.display = "block";

      if (msg.loser === playerIndex) {
        resultText.textContent = "You spelled MAGIC! You lose!";
      } else {
        resultText.textContent = "Opponent spelled MAGIC! You win!";
      }
      break;

    case "opponent-disconnected":
      showMessage("Opponent disconnected!");
      turnInfo.textContent = "Game ended";
      hideAllControls();
      break;

    case "error":
      alert(msg.message);
      break;
  }
}

Then finally to make everything work you have to connect client to server with WebSockets like so:

function connectWebSocket() {
  const protocol = location.protocol === "https:" ? "wss:" : "ws:";
  const testUrl = `${protocol}//localhost:3001`; // Local testing
  const wsUrl = `${protocol}//api.pianowizards.andrewklundt.com`;
  ws = new WebSocket(wsUrl);

  ws.onopen = () => {
    console.log("Connected to server");

    // Check if joining via URL
    const pathRoomId = location.pathname.slice(1);
    if (pathRoomId && pathRoomId.length === 8) {
      ws.send(JSON.stringify({ type: "join-room", roomId: pathRoomId }));
    }
  };

  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    handleMessage(msg);
  };

  ws.onclose = () => {
    console.log("Disconnected from server");
  };
}

Challenges we ran into

One of our biggest challenges was merging, it was easier because of git, but even with github it was a tumultuous time. The main reason was our differing setups and some of us forgetting to pull and push causing a massive gap between commits. Other than just merging, the stave creation was difficult because of the math, like how long are certain notes in ms, what quantifies what? A major issue was our lack of experience with WebSockets, so at times a commit would destroy everything and we wouldn't realize that misinformation was being spread in the backend. Another difficulty was the musical knowledge needed, most of the team had experience in band or piano, but some of us were very uninformed in how music is read and made.

Accomplishments that we're proud of

The way it looks is fantastic, it actually runs, and we added every main feature we wanted. Everything looks great and the game itself is fun

What we learned

We learned a lot about websockets, midi, and DOM without using frameworks like react. Before this project we never used websockets and rarely used audio in html as fluidly. Our team member Krish learned a lot about musical composition and how sheet music is made.

What's next for Piano Wizards

  • Wand animation when round is complete
  • Other modes like sightreading accuracy versus or note memorization typing solo mode
  • Making the online more stable and ability to reconnect
  • User made songs to play (similar to games like beatsaber)

Built With

Share this project:

Updates