DEV Community

Madhumitha
Madhumitha

Posted on

Portion Quest: A 60-Second Hidden Object Game Set in a Witch's Haunted Forest ๐Ÿง™โ€โ™€๏ธ๐ŸŽƒ

Hacktoberfest Open-Source AI Challenge: Ghost in the Machine Submission ๐Ÿ‘ป๐ŸŒผ

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine

What I Built

Portion Quest is a spooky hidden-object game that runs in your browser. A witch has lost her magical ingredients in a dark enchanted forest, and you have 60 seconds to find all 25 of them before her potion fails.

You start with a score of 0 and earn a point for every ingredient you find. If you get stuck, you have 5 hints, and each one highlights where an ingredient is hiding. Find everything in time and you get a "Potion Complete!" screen. Run out of time and you can hit Play Again.

How I interpreted the theme: Halloween is the night the line between worlds gets thin, and I wanted a forest that feels alive. The ghost in this machine is the witch herself. She greets you on the title screen, hands you your quest, and her haunted forest hides everything you need to find.

Demo

๐ŸŽฎ Play it here: portion-quest-game.netlify.app

๐Ÿ“ฑ Playing on a phone? Turn it sideways. The game asks you to rotate your device so the whole forest fits on screen.

Screenshots

Desktop:

Mobile

Code

HTML:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />

    <meta
      name="viewport"
      content="width=device-width,
                 initial-scale=1.0,
                 maximum-scale=1.0,
                 user-scalable=no"
    />

    <title>Portion Quest</title>

    <link rel="stylesheet" href="style.css" />
    <link rel="icon" type="image/x-icon" href="logo.png"/>
  </head>

  <body>
    <!-- =====================================================
     ROTATE DEVICE MESSAGE
     ===================================================== -->

    <div class="rotate-screen">
      <div class="rotate-content">
        <div class="rotate-icon">๐Ÿ“ฑ</div>

        <h2>Rotate Your Device</h2>

        <p>
          Please turn your phone sideways to play the Witch's Enchanted Forest.
        </p>
      </div>
    </div>

    <!-- =====================================================
     MAIN GAME
     ===================================================== -->

    <main class="game-container">
      <!-- =================================================
         FOREST
         ================================================= -->

      <section class="image-section">
        <img
          src="GameBG.png"
          class="forest-image"
          alt="Spooky enchanted forest"
          draggable="false"
        />
      </section>

      <!-- =================================================
         INGREDIENT PANEL
         ================================================= -->

      <aside class="ingredients-panel">
        <!-- SCORE / TIMER / HINT -->

        <div class="hud">
          <div class="hud-box">
            ๐Ÿงช Score:
            <span id="score">0</span>
            /
            <span id="totalScore">25</span>
          </div>

          <div class="hud-box">
            โณ Time:
            <span id="timer">60</span>s
          </div>

          <button id="hintButton" class="hint-button" type="button">
            ๐Ÿ’ก Hint:
            <span id="hintCount">5</span>
          </button>
        </div>

        <!-- TITLE -->

        <h2>๐Ÿ“œ Ingredients</h2>

        <p class="panel-subtitle">
          Find every magical ingredient hidden in the forest before time runs
          out!
        </p>

        <!-- PROGRESS -->

        <div class="progress-container">
          <div id="progressBar"></div>
        </div>

        <!-- INGREDIENT LIST -->

        <div id="ingredientList"></div>
      </aside>
    </main>

    <!-- =====================================================
     FOUND MESSAGE
     ===================================================== -->

    <div id="foundMessage"></div>

    <!-- =====================================================
     START OVERLAY
     ===================================================== -->

    <div id="startOverlay" class="overlay">
      <div class="modal start-modal">
        <img src="logo.png" width="50" height="50" alt="" />
        <h2>Portion Quest</h2>

        <div class="witch-start-art">
          <img
            src="witch.png"
            class="witch-start-image"
            alt="A witch invites you to find ingredients in the dark forest"
          />
          <div class="start-actions">
            <button class="game-button" id="startButton" type="button">
              Start Game
            </button>
            <button
              class="game-button rules-button"
              id="rulesButton"
              type="button"
              aria-haspopup="dialog"
              aria-controls="rulesOverlay"
            >
              Rules
            </button>
          </div>
        </div>
      </div>
    </div>

    <div
      id="rulesOverlay"
      class="overlay rules-overlay hidden"
      role="dialog"
      aria-modal="true"
      aria-labelledby="rulesTitle"
    >
      <div class="modal rules-modal">
        <button
          class="rules-close"
          id="closeRulesButton"
          type="button"
          aria-label="Close rules"
        >
          &times;
        </button>
        <h2 id="rulesTitle">How to Play</h2>
        <ul class="game-rules">
          <li>Find all 25 ingredients hidden in the dark forest.</li>
          <li>You have 60 seconds to find them all.</li>
          <li>Your score starts at 0. Each ingredient adds 1 point.</li>
          <li>
            ๐Ÿ’ก You have <strong>5 hints</strong>. Each hint highlights one
            ingredient's location.
          </li>
        </ul>
      </div>
    </div>

    <!-- =====================================================
     END OVERLAY
     ===================================================== -->

    <div id="endOverlay" class="overlay hidden">
      <div class="modal">
        <h2 id="endTitle"> Potion Complete!</h2>

        <p id="endText">You found all the magical ingredients!</p>

        <button class="game-button" id="restartButton">Play Again</button>
      </div>
    </div>

    <script src="script.js"></script>
  </body>
</html>

Enter fullscreen mode Exit fullscreen mode

CSS:

/* =========================================================
   RESET
   ========================================================= */

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body {
  width: 100%;
  height: 100%;

  margin: 0;
  padding: 0;

  overflow: hidden;

  overscroll-behavior: none;

  background: #020304;
}

body {
  min-height: 100dvh;
  height: 100dvh;

  color: white;

  font-family: Georgia, "Times New Roman", serif;
}

/* =========================================================
   MAIN GAME
   ========================================================= */

.game-container {
  width: 100%;
  height: 100dvh;

  padding: 12px;

  display: grid;

  grid-template-columns:
    minmax(0, 3fr)
    minmax(300px, 1fr);

  gap: 12px;

  overflow: hidden;
}

/* =========================================================
   FOREST IMAGE SECTION
   ========================================================= */

.image-section {
  position: relative;

  width: 100%;
  height: 100%;

  overflow: hidden;

  border-radius: 18px;

  background: #000;

  border: 2px solid rgba(180, 255, 100, 0.3);

  box-shadow:
    0 0 35px rgba(0, 0, 0, 0.8),
    inset 0 0 40px rgba(0, 0, 0, 0.3);
}

.forest-image {
  width: 100%;
  height: 100%;

  display: block;

  object-fit: cover;

  object-position: center;

  user-select: none;

  -webkit-user-drag: none;
}

/* Dark atmosphere */

.image-section::after {
  content: "";

  position: absolute;

  inset: 0;

  pointer-events: none;

  z-index: 3;

  background: radial-gradient(
    circle at center,
    transparent 45%,
    rgba(0, 0, 0, 0.25)
  );
}

/* =========================================================
   HIDDEN OBJECT HOTSPOTS
   ========================================================= */

.ingredient {
  position: absolute;

  z-index: 10;

  transform: translate(-50%, -50%);

  background: transparent;

  border: 2px solid transparent;

  border-radius: 50%;

  touch-action: manipulation;

  transition: 0.2s;
}

/* Increase invisible touch area */

.ingredient::before {
  content: "";

  position: absolute;

  inset: -8px;

  border-radius: 50%;
}

/* Desktop hover */

/* =========================================================
   HIDDEN OBJECT HOVER
   Keep hidden objects completely invisible.
   Only the hint system should reveal a highlight.
   ========================================================= */

.ingredient:hover {
  background: transparent;
  border-color: transparent;
  box-shadow: none;
}

/* Found animation */

.ingredient.found {
  pointer-events: none;

  animation: foundAnimation 0.6s forwards;
}

@keyframes foundAnimation {
  0% {
    transform: translate(-50%, -50%) scale(1);

    opacity: 1;
  }

  45% {
    transform: translate(-50%, -50%) scale(1.4);

    background: rgba(180, 255, 80, 0.4);

    box-shadow: 0 0 30px #baff70;
  }

  100% {
    transform: translate(-50%, -50%) scale(2);

    opacity: 0;
  }
}

/* =========================================================
   INGREDIENT PANEL
   ========================================================= */

.ingredients-panel {
  width: 100%;
  height: 100%;

  container-type: inline-size;

  min-height: 0;

  display: flex;

  flex-direction: column;

  overflow: hidden;

  padding: 20px;

  border-radius: 18px;

  background: linear-gradient(
    145deg,
    rgba(23, 19, 32, 0.98),
    rgba(7, 10, 16, 0.98)
  );

  border: 1px solid rgba(180, 130, 255, 0.35);

  box-shadow: 0 0 30px rgba(0, 0, 0, 0.65);
}

/* =========================================================
   HUD
   ========================================================= */

.hud {
  display: flex;

  justify-content: center;

  align-items: center;

  gap: 10px;

  width: 100%;

  margin-bottom: 8px;

  flex-shrink: 0;
}

@container (max-width: 410px) {
  .hud {
    display: grid;

    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .hud-box,
  .hint-button {
    min-width: 0;

    width: 100%;
  }

  .hud-box {
    padding: clamp(3px, 0.7vw, 8px) clamp(2px, 0.6vw, 8px);

    font-size: clamp(0.52rem, 1.1vw, 0.84rem);
  }

  .hint-button {
    grid-column: 1 / -1;
  }
}

.hud-box {
  flex: 1;

  min-width: 0;

  padding: 9px 10px;

  text-align: center;

  border-radius: 25px;

  background: rgba(0, 0, 0, 0.65);

  border: 1px solid rgba(190, 255, 110, 0.4);

  box-shadow: 0 0 15px rgba(130, 255, 80, 0.12);

  font-weight: bold;

  white-space: nowrap;
}

#score {
  color: #baff70;
}

#timer {
  color: #ffd96a;
}

#timer.warning {
  color: #ff5757;

  animation: timerWarning 0.5s infinite alternate;
}

@keyframes timerWarning {
  from {
    transform: scale(1);
  }

  to {
    transform: scale(1.08);

    text-shadow: 0 0 15px red;
  }
}

/* =========================================================
   HINT BUTTON
   ========================================================= */

.hint-button {
  flex: 0 0 auto;

  padding: 9px 14px;

  border: 1px solid #ffe45c;

  border-radius: 25px;

  background: linear-gradient(145deg, #3d3513, #171405);

  color: #ffe96b;

  font-family: Georgia, "Times New Roman", serif;

  font-weight: bold;

  cursor: pointer;

  white-space: nowrap;

  box-shadow: 0 0 10px rgba(255, 225, 70, 0.25);

  transition: 0.2s ease;
}

.hint-button:hover {
  transform: scale(1.05);

  box-shadow: 0 0 20px rgba(255, 225, 70, 0.6);
}

.hint-button:active {
  transform: scale(0.95);
}

.hint-button.disabled {
  opacity: 0.35;

  cursor: not-allowed;

  box-shadow: none;
}

.hint-button.disabled:hover {
  transform: none;
}

/* =========================================================
   INGREDIENT TITLE
   ========================================================= */

.ingredients-panel h2 {
  flex-shrink: 0;

  text-align: center;

  color: #caff82;

  font-size: 1.45rem;

  margin-bottom: 5px;

  text-shadow: 0 0 10px rgba(180, 255, 100, 0.25);
}

.panel-subtitle {
  flex-shrink: 0;

  text-align: center;

  color: #aaa0b6;

  font-size: 0.78rem;

  line-height: 1.2;

  margin-bottom: 15px;
}

/* =========================================================
   PROGRESS BAR
   ========================================================= */

.progress-container {
  flex-shrink: 0;

  width: 100%;

  height: 9px;

  margin-bottom: 18px;

  background: rgba(255, 255, 255, 0.08);

  border-radius: 20px;

  overflow: hidden;
}

#progressBar {
  width: 0%;

  height: 100%;

  background: linear-gradient(90deg, #72c84a, #d3ff80);

  box-shadow: 0 0 10px #9cff65;

  transition: width 0.3s ease;
}

/* =========================================================
   INGREDIENT LIST
   ========================================================= */

#ingredientList {
  flex: 1;

  min-height: 0;

  display: flex;

  flex-direction: column;

  gap: 7px;

  overflow-y: auto;

  overflow-x: hidden;

  padding-right: 5px;

  scrollbar-width: thin;

  scrollbar-color: rgba(180, 255, 100, 0.35) rgba(255, 255, 255, 0.04);
}

/* Chrome / Edge scrollbar */

#ingredientList::-webkit-scrollbar {
  width: 6px;
}

#ingredientList::-webkit-scrollbar-track {
  background: rgba(255, 255, 255, 0.04);
}

#ingredientList::-webkit-scrollbar-thumb {
  background: rgba(180, 255, 100, 0.35);

  border-radius: 10px;
}

/* =========================================================
   INGREDIENT LIST ITEM
   ========================================================= */

.list-item {
  flex-shrink: 0;

  display: flex;

  align-items: center;

  gap: 9px;

  padding: 9px 11px;

  border-radius: 8px;

  background: rgba(255, 255, 255, 0.045);

  border: 1px solid transparent;

  color: #eee7f2;

  font-size: 0.88rem;

  transition: 0.25s;
}

.list-number {
  min-width: 23px;

  height: 23px;

  display: flex;

  align-items: center;

  justify-content: center;

  flex-shrink: 0;

  border-radius: 50%;

  background: rgba(255, 255, 255, 0.08);

  color: #aaa;

  font-size: 0.72rem;
}

/* Found item */

.list-item.found {
  color: #71806c;

  background: rgba(100, 255, 100, 0.06);

  text-decoration: line-through;

  border-color: rgba(120, 255, 100, 0.15);
}

.list-item.found .list-number {
  background: #65a747;

  color: white;
}

/* =========================================================
   EXACT HINT HIGHLIGHT
   ========================================================= */

.hint-highlight {
  position: absolute;

  z-index: 50;

  transform: translate(-50%, -50%);

  /*
       Exact same position and size
       as the hidden hotspot.
    */

  border: 3px solid #ffe84d;

  background: rgba(255, 230, 70, 0.2);

  box-shadow:
    0 0 8px #fff36a,
    0 0 18px #ffe84d,
    0 0 35px #ffc800,
    inset 0 0 15px rgba(255, 245, 120, 0.35);

  pointer-events: none;

  animation: selectedObject 0.65s infinite alternate;
}

/* Rotating selection border */

.hint-highlight::before {
  content: "";

  position: absolute;

  inset: -6px;

  border: 2px dashed #fff17a;

  box-shadow: 0 0 12px #ffe84d;

  animation: selectionRotate 1.5s linear infinite;
}

/* Center target */

.hint-highlight::after {
  content: "โœฆ";

  position: absolute;

  left: 50%;
  top: 50%;

  transform: translate(-50%, -50%);

  color: #fffbd0;

  font-size: 22px;

  font-weight: bold;

  text-shadow:
    0 0 8px #fff,
    0 0 18px #ffe84d;

  animation: targetPulse 0.6s infinite alternate;
}

@keyframes selectedObject {
  from {
    opacity: 0.65;

    filter: brightness(1);

    box-shadow:
      0 0 8px #fff36a,
      0 0 18px #ffe84d,
      0 0 30px #ffc800,
      inset 0 0 10px rgba(255, 245, 120, 0.25);
  }

  to {
    opacity: 1;

    filter: brightness(1.5);

    box-shadow:
      0 0 15px #fff36a,
      0 0 30px #ffe84d,
      0 0 55px #ffc800,
      inset 0 0 20px rgba(255, 245, 120, 0.45);
  }
}

@keyframes selectionRotate {
  from {
    transform: rotate(0deg);
  }

  to {
    transform: rotate(360deg);
  }
}

@keyframes targetPulse {
  from {
    transform: translate(-50%, -50%) scale(0.7);

    opacity: 0.5;
  }

  to {
    transform: translate(-50%, -50%) scale(1.3);

    opacity: 1;
  }
}

/* =========================================================
   FOUND MESSAGE
   ========================================================= */

#foundMessage {
  position: fixed;

  left: 50%;

  top: 15%;

  z-index: 1000;

  transform: translateX(-50%);

  padding: 11px 22px;

  border-radius: 30px;

  background: rgba(0, 0, 0, 0.9);

  border: 1px solid rgba(190, 255, 100, 0.5);

  color: #caff82;

  font-weight: bold;

  pointer-events: none;

  opacity: 0;
}

#foundMessage.show {
  animation: messageAnimation 1s ease;
}

@keyframes messageAnimation {
  0% {
    opacity: 0;

    transform: translate(-50%, 20px) scale(0.8);
  }

  20% {
    opacity: 1;

    transform: translate(-50%, 0) scale(1.08);
  }

  75% {
    opacity: 1;
  }

  100% {
    opacity: 0;

    transform: translate(-50%, -20px);
  }
}

/* =========================================================
   HINT MESSAGE
   ========================================================= */

.hint-message {
  position: fixed;

  left: 50%;

  top: 15%;

  z-index: 1500;

  transform: translateX(-50%);

  padding: 10px 20px;

  border-radius: 25px;

  background: rgba(15, 12, 3, 0.95);

  border: 1px solid #ffe84d;

  color: #ffe96b;

  font-weight: bold;

  box-shadow: 0 0 20px rgba(255, 220, 70, 0.5);

  pointer-events: none;

  animation: hintMessageAnimation 2s ease forwards;
}

@keyframes hintMessageAnimation {
  0% {
    opacity: 0;

    transform: translate(-50%, 15px) scale(0.8);
  }

  15% {
    opacity: 1;

    transform: translate(-50%, 0) scale(1.05);
  }

  75% {
    opacity: 1;
  }

  100% {
    opacity: 0;

    transform: translate(-50%, -20px) scale(0.95);
  }
}

/* =========================================================
   START / END OVERLAY
   ========================================================= */

.overlay {
  position: fixed;

  inset: 0;

  z-index: 2000;

  display: flex;

  align-items: center;

  justify-content: center;

  padding: 20px;

  background: rgba(0, 0, 0, 0.88);

  backdrop-filter: blur(8px);
}

.modal {
  width: min(550px, 100%);

  padding: 35px;

  text-align: center;

  border-radius: 22px;

  background: radial-gradient(circle at top, #35204b, #0b0910);

  border: 2px solid #81a94b;

  box-shadow: 0 0 45px rgba(150, 255, 90, 0.18);
}

.start-modal {
  max-height: calc(100dvh - 40px);

  overflow-y: auto;
}

.modal h2 {
  color: #caff82;

  font-size: 2rem;

  margin-bottom: 15px;
}

.modal p {
  color: #ddd4e5;

  line-height: 1.6;

  margin-bottom: 22px;
}

.rules-heading {
  color: #caff82;

  font-size: 1.15rem;

  margin-bottom: 10px;
}

.game-rules {
  display: grid;

  gap: 8px;

  margin: 0 0 22px;

  padding-left: 22px;

  color: #ddd4e5;

  text-align: left;

  line-height: 1.4;
}

.rules-overlay {
  z-index: 2100;
}

.rules-modal {
  position: relative;

  width: min(440px, 100%);

  padding: 32px;
}

.rules-modal .game-rules {
  margin-bottom: 0;
}

.rules-close {
  position: absolute;

  top: 10px;
  right: 14px;

  border: 0;

  background: transparent;

  color: #ddd4e5;

  font-size: 2rem;

  line-height: 1;

  cursor: pointer;
}

.rules-close:hover {
  color: #caff82;
}

.witch-start-art {
  position: relative;

  width: 100%;

  overflow: hidden;

  border: 1px solid rgba(202, 255, 130, 0.45);

  border-radius: 14px;

  box-shadow: 0 0 22px rgba(150, 255, 90, 0.2);
}

.witch-start-image {
  display: block;

  width: 100%;

  height: auto;
}

.start-actions {
  position: absolute;

  right: 7%;

  bottom: 12%;

  display: flex;

  gap: 10px;
}

.start-actions .game-button {
  padding: 11px 22px;

  font-size: clamp(0.8rem, 2.4vw, 1rem);
}

.game-button {
  border: none;

  padding: 13px 28px;

  border-radius: 30px;

  cursor: pointer;

  font-weight: bold;

  background: linear-gradient(#d7ff99, #7bc846);

  color: #16200d;

  box-shadow: 0 0 20px rgba(150, 255, 80, 0.3);

  transition: 0.2s;
}

.game-button:hover {
  transform: scale(1.06);

  box-shadow: 0 0 30px rgba(150, 255, 80, 0.6);
}

.hidden {
  display: none !important;
}

/* =========================================================
   LANDSCAPE TABLETS / PHONES
   ========================================================= */

@media screen and (max-width: 900px) and (orientation: landscape) {
  .game-container {
    height: 100dvh;

    padding: 6px;

    gap: 7px;

    grid-template-columns:
      minmax(0, 3fr)
      minmax(200px, 1fr);
  }

  .ingredients-panel {
    padding: 9px;

    border-radius: 10px;
  }

  .hud {
    gap: 5px;

    margin-bottom: 5px;
  }

  .hud-box {
    padding: 4px 4px;

    font-size: 0.58rem;
  }

  .hint-button {
    padding: 5px 8px;

    font-size: 0.68rem;
  }

  .ingredients-panel h2 {
    font-size: 1rem;

    margin-bottom: 3px;
  }

  .panel-subtitle {
    font-size: 0.62rem;

    margin-bottom: 5px;
  }

  .progress-container {
    height: 5px;

    margin-bottom: 7px;
  }

  #ingredientList {
    gap: 3px;
  }

  .list-item {
    padding: 4px 6px;

    font-size: 0.68rem;

    gap: 5px;
  }

  .list-number {
    min-width: 17px;

    height: 17px;

    font-size: 0.55rem;
  }
}

/* =========================================================
   SMALL LANDSCAPE PHONES
   ========================================================= */

@media screen and (max-width: 650px) and (orientation: landscape) {
  .game-container {
    padding: 4px;

    gap: 5px;

    grid-template-columns:
      minmax(0, 3.2fr)
      minmax(165px, 1fr);
  }

  .ingredients-panel {
    padding: 6px;
  }

  .hud {
    gap: 3px;
  }

  .hud-box {
    font-size: 0.52rem;

    padding: 3px 2px;
  }

  .hint-button {
    padding: 3px 5px;

    font-size: 0.58rem;
  }

  .ingredients-panel h2 {
    font-size: 0.85rem;
  }

  .panel-subtitle {
    display: none;
  }

  .progress-container {
    height: 4px;

    margin-bottom: 5px;
  }

  #ingredientList {
    gap: 2px;
  }

  .list-item {
    font-size: 0.58rem;

    padding: 3px 4px;
  }

  .list-number {
    min-width: 15px;

    height: 15px;

    font-size: 0.5rem;
  }
}

/* =========================================================
   PORTRAIT MODE
   ========================================================= */

@media screen and (max-width: 900px) and (orientation: portrait) {
  .game-container {
    display: none;
  }

  body {
    overflow: hidden;
  }
}

/* =========================================================
   ROTATE DEVICE SCREEN
   ========================================================= */

.rotate-screen {
  display: none;

  position: fixed;

  inset: 0;

  z-index: 5000;

  align-items: center;

  justify-content: center;

  text-align: center;

  padding: 25px;

  background: radial-gradient(circle at center, #211735, #050609);

  color: #caff82;
}

.rotate-content {
  max-width: 350px;
}

.rotate-content .rotate-icon {
  font-size: 4rem;

  margin-bottom: 15px;

  animation: rotatePhone 2s infinite ease-in-out;
}

.rotate-content h2 {
  font-size: 1.6rem;

  margin-bottom: 10px;
}

.rotate-content p {
  color: #c7bdcf;

  line-height: 1.5;
}

@keyframes rotatePhone {
  0%,
  100% {
    transform: rotate(0deg);
  }

  50% {
    transform: rotate(90deg);
  }
}

@media screen and (max-width: 900px) and (orientation: portrait) {
  .rotate-screen {
    display: flex;
  }
}

Enter fullscreen mode Exit fullscreen mode

JavaScript:

/* =========================================================
   INGREDIENT DATA
   ========================================================= */

const ingredients = [
  {
    name: "Mandrake Root",
    icon: "๐ŸŒฑ",
    x: 8,
    y: 31,
    w: 8,
    h: 17,
  },

  {
    name: "Spider",
    icon: "๐Ÿ•ท๏ธ",
    x: 32.5,
    y: 12,
    w: 5,
    h: 9,
  },

  {
    name: "Bat Wing",
    icon: "๐Ÿฆ‡",
    x: 72,
    y: 8,
    w: 9,
    h: 8,
  },

  {
    name: "Poison Mushroom",
    icon: "๐Ÿ„",
    x: 24,
    y: 29,
    w: 4,
    h: 7,
  },

  {
    name: "Snake Scale",
    icon: "๐Ÿ",
    x: 38,
    y: 37,
    w: 5,
    h: 5,
  },

  {
    name: "Frog",
    icon: "๐Ÿธ",
    x: 45.5,
    y: 67,
    w: 6,
    h: 7,
  },

  {
    name: "Black Feather",
    icon: "๐Ÿชถ",
    x: 30,
    y: 57,
    w: 12,
    h: 8,
  },

  {
    name: "Magic Crystal",
    icon: "๐Ÿ’Ž",
    x: 17.5,
    y: 35,
    w: 5,
    h: 10,
  },

  {
    name: "Ancient Bone",
    icon: "๐Ÿฆด",
    x: 34,
    y: 79,
    w: 8,
    h: 9,
  },

  {
    name: "Eyeball",
    icon: "๐Ÿ‘๏ธ",
    x: 38.5,
    y: 79,
    w: 5,
    h: 7,
  },

  {
    name: "Moon Herb",
    icon: "๐ŸŒฟ",
    x: 85,
    y: 29,
    w: 7,
    h: 16,
  },

  {
    name: "Ghost Essence",
    icon: "๐Ÿ‘ป",
    x: 52.5,
    y: 46,
    w: 7,
    h: 13,
  },

  {
    name: "Pumpkin Seeds",
    icon: "๐ŸŽƒ",
    x: 67.2,
    y: 88.4,
    w: 8,
    h: 10,
  },

  {
    name: "Scorpion",
    icon: "๐Ÿฆ‚",
    x: 26,
    y: 86,
    w: 7,
    h: 10,
  },

  {
    name: "Snail Slime",
    icon: "๐ŸŒ",
    x: 15,
    y: 63,
    w: 8,
    h: 8,
  },

  {
    name: "Dragon Flame",
    icon: "๐Ÿ”ฅ",
    x: 72,
    y: 19,
    w: 7,
    h: 8,
  },

  {
    name: "Magic Apple",
    icon: "๐ŸŽ",
    x: 75,
    y: 69,
    w: 6,
    h: 8,
  },

  {
    name: "Night Flower",
    icon: "๐ŸŒบ",
    x: 96,
    y: 69,
    w: 6,
    h: 10,
  },

  {
    name: "Raven Skull",
    icon: "๐Ÿ’€",
    x: 12,
    y: 50,
    w: 7,
    h: 7,
  },

  {
    name: "Star Dust",
    icon: "โญ",
    x: 60,
    y: 10,
    w: 10,
    h: 10,
  },

  {
    name: "Magic Potion",
    icon: "๐Ÿงช",
    x: 92,
    y: 85,
    w: 9,
    h: 18,
  },

  {
    name: "Mystic Key",
    icon: "๐Ÿ—๏ธ",
    x: 93,
    y: 38,
    w: 7,
    h: 12,
  },

  {
    name: "Cursed Candle",
    icon: "๐Ÿ•ฏ๏ธ",
    x: 82,
    y: 87,
    w: 8,
    h: 18,
  },

  {
    name: "Dark Berry",
    icon: "๐Ÿซ",
    x: 5,
    y: 61,
    w: 7,
    h: 8,
  },

  {
    name: "Ancient Scroll",
    icon: "๐Ÿ“œ",
    x: 6,
    y: 74,
    w: 10,
    h: 14,
  },
];

/* =========================================================
   GAME VARIABLES
   ========================================================= */

let score = 0;

let timeLeft = 60;

let gameActive = false;

let timerInterval = null;

/*
   HINT SYSTEM

   Start with 5 hints.
*/

let hintsRemaining = 5;

/*
   Currently highlighted hint.
*/

let currentHint = null;

/*
   Index of currently highlighted object.
*/

let currentHintIndex = -1;

/* =========================================================
   DOM ELEMENTS
   ========================================================= */

const scoreDisplay = document.getElementById("score");

const totalScoreDisplay = document.getElementById("totalScore");

const timerDisplay = document.getElementById("timer");

const progressBar = document.getElementById("progressBar");

const ingredientList = document.getElementById("ingredientList");

const foundMessage = document.getElementById("foundMessage");

const startOverlay = document.getElementById("startOverlay");

const endOverlay = document.getElementById("endOverlay");

const endTitle = document.getElementById("endTitle");

const endText = document.getElementById("endText");

/*
   Hint elements
*/

const hintButton = document.getElementById("hintButton");

const hintCount = document.getElementById("hintCount");

/*
   Total ingredients
*/

totalScoreDisplay.textContent = ingredients.length;

/* =========================================================
   CREATE CHECKLIST
   ========================================================= */

function createChecklist() {
  ingredientList.innerHTML = "";

  ingredients.forEach((ingredient, index) => {
    const item = document.createElement("div");

    item.className = "list-item";

    item.id = `list-${index}`;

    item.innerHTML = `

                <span class="list-number">
                    ${index + 1}
                </span>

                <span>
                    ${ingredient.icon}
                    ${ingredient.name}
                </span>

            `;

    ingredientList.appendChild(item);
  });
}

/* =========================================================
   CREATE HOTSPOTS
   ========================================================= */

function createHotspots() {
  /*
       Remove old hotspots
    */

  document
    .querySelectorAll(".ingredient")
    .forEach((element) => element.remove());

  const imageSection = document.querySelector(".image-section");

  ingredients.forEach((ingredient, index) => {
    const hotspot = document.createElement("button");

    hotspot.className = "ingredient";

    hotspot.type = "button";

    hotspot.setAttribute("aria-label", `Find ${ingredient.name}`);

    /*
               Position
            */

    hotspot.style.left = ingredient.x + "%";

    hotspot.style.top = ingredient.y + "%";

    /*
               Exact clickable size
            */

    hotspot.style.width = ingredient.w + "%";

    hotspot.style.height = ingredient.h + "%";

    /*
               Click event
            */

    hotspot.addEventListener("click", () => {
      collectIngredient(hotspot, ingredient, index);
    });

    imageSection.appendChild(hotspot);
  });
}

/* =========================================================
   COLLECT INGREDIENT
   ========================================================= */

function collectIngredient(hotspot, ingredient, index) {
  /*
       Ignore clicks when game isn't active
    */

  if (!gameActive || hotspot.classList.contains("found")) {
    return;
  }

  /*
       Mark object found
    */

  hotspot.classList.add("found");

  /*
       If this is the object currently
       highlighted by the hint,
       remove the hint.
    */

  if (currentHintIndex === index) {
    removeHint();
  }

  /*
       Increase score
    */

  score++;

  scoreDisplay.textContent = score;

  /*
       Mark checklist item
    */

  const listItem = document.getElementById(`list-${index}`);

  if (listItem) {
    listItem.classList.add("found");
  }

  /*
       Update progress
    */

  const progress = (score / ingredients.length) * 100;

  progressBar.style.width = progress + "%";

  /*
       Show message
    */

  showFoundMessage(`โœจ +1 ${ingredient.icon} ${ingredient.name}`);

  /*
       Win
    */

  if (score === ingredients.length) {
    setTimeout(() => {
      endGame(true);
    }, 700);
  }
}

/* =========================================================
   FOUND MESSAGE
   ========================================================= */

function showFoundMessage(message) {
  foundMessage.textContent = message;

  foundMessage.classList.remove("show");

  /*
       Restart CSS animation
    */

  void foundMessage.offsetWidth;

  foundMessage.classList.add("show");
}

/* =========================================================
   USE HINT
   ========================================================= */

function useHint() {
  /*
       Game must be running
    */

  if (!gameActive) {
    return;
  }

  /*
       No hints remaining
    */

  if (hintsRemaining <= 0) {
    return;
  }

  /*
       Remove an existing highlight
    */

  removeHint();

  /*
       Find all unfound objects
    */

  const availableIndexes = [];

  ingredients.forEach((ingredient, index) => {
    const listItem = document.getElementById(`list-${index}`);

    if (listItem && !listItem.classList.contains("found")) {
      availableIndexes.push(index);
    }
  });

  /*
       Nothing left
    */

  if (availableIndexes.length === 0) {
    return;
  }

  /*
       Select random hidden object
    */

  const selectedIndex =
    availableIndexes[Math.floor(Math.random() * availableIndexes.length)];

  const ingredient = ingredients[selectedIndex];

  /*
       Save selected object
    */

  currentHintIndex = selectedIndex;

  /*
       Decrease hint count

       5 -> 4
       4 -> 3
       3 -> 2
       2 -> 1
       1 -> 0
    */

  hintsRemaining--;

  hintCount.textContent = hintsRemaining;

  /*
       Disable button at 0
    */

  if (hintsRemaining === 0) {
    hintButton.classList.add("disabled");
  }

  /*
       Create highlight
    */

  const highlight = document.createElement("div");

  highlight.className = "hint-highlight";

  highlight.style.left = `${ingredient.x}%`;

  highlight.style.top = `${ingredient.y}%`;

  highlight.style.width = `${ingredient.w}%`;

  highlight.style.height = `${ingredient.h}%`;

  /*
       Add highlight to forest
    */

  document.querySelector(".image-section").appendChild(highlight);

  /*
       Save current highlight
    */

  currentHint = highlight;

  /*
       Tell player which object
       they should search for.
    */

  showHintMessage(`๐Ÿ’ก Hint used! Find ${ingredient.icon} ${ingredient.name}`);
}

/* =========================================================
   REMOVE HINT
   ========================================================= */

function removeHint() {
  if (currentHint) {
    currentHint.remove();

    currentHint = null;
  }

  currentHintIndex = -1;
}

/* =========================================================
   HINT MESSAGE
   ========================================================= */

function showHintMessage(message) {
  /*
       Remove old message
    */

  const oldMessage = document.querySelector(".hint-message");

  if (oldMessage) {
    oldMessage.remove();
  }

  /*
       Create message
    */

  const messageElement = document.createElement("div");

  messageElement.className = "hint-message";

  messageElement.textContent = message;

  document.body.appendChild(messageElement);

  /*
       Remove after animation
    */

  setTimeout(() => {
    if (messageElement.parentNode) {
      messageElement.remove();
    }
  }, 2200);
}

/* =========================================================
   START GAME
   ========================================================= */

function startGame() {
  /*
       Stop previous timer
    */

  clearInterval(timerInterval);

  /*
       Reset game
    */

  score = 0;

  timeLeft = 60;

  gameActive = true;

  /*
       RESET HINTS TO 5
    */

  hintsRemaining = 5;

  hintCount.textContent = "5";

  hintButton.classList.remove("disabled");

  /*
       Remove previous hint
    */

  removeHint();

  /*
       Reset HUD
    */

  scoreDisplay.textContent = "0";

  timerDisplay.textContent = "60";

  timerDisplay.classList.remove("warning");

  progressBar.style.width = "0%";

  /*
       Hide overlays
    */

  startOverlay.classList.add("hidden");

  endOverlay.classList.add("hidden");

  /*
       Build game
    */

  createChecklist();

  createHotspots();

  /*
       Start timer
    */

  timerInterval = setInterval(() => {
    timeLeft--;

    timerDisplay.textContent = timeLeft;

    /*
                   Warning at 10 seconds
                */

    if (timeLeft <= 10) {
      timerDisplay.classList.add("warning");
    }

    /*
                   Time up
                */

    if (timeLeft <= 0) {
      endGame(false);
    }
  }, 1000);
}

/* =========================================================
   END GAME
   ========================================================= */

function endGame(won) {
  /*
       Prevent duplicate end calls
    */

  if (!gameActive) {
    return;
  }

  /*
       Stop game
    */

  gameActive = false;

  /*
       Stop timer
    */

  clearInterval(timerInterval);

  /*
       Remove hint highlight
    */

  removeHint();

  /*
       Winning screen
    */

  if (won) {
    endTitle.textContent = "Potion Complete!";

    endText.textContent = `Amazing! You found all ${ingredients.length} magical ingredients and created the ultimate potion!`;
  } else {

  /*
       Losing screen
    */
    endTitle.textContent = "โฐ Time's Up!";

    endText.textContent = `You found ${score} out of ${ingredients.length} ingredients. Try again and search faster!`;
  }

  /*
       Show end screen
    */

  endOverlay.classList.remove("hidden");
}

/* =========================================================
   BUTTON EVENTS
   ========================================================= */

/*
   Start
*/

document.getElementById("startButton").addEventListener("click", startGame);

/*
   Restart
*/

document.getElementById("restartButton").addEventListener("click", startGame);

const rulesButton = document.getElementById("rulesButton");
const rulesOverlay = document.getElementById("rulesOverlay");
const closeRulesButton = document.getElementById("closeRulesButton");

function closeRules() {
  rulesOverlay.classList.add("hidden");
  rulesButton.focus();
}

rulesButton.addEventListener("click", () => {
  rulesOverlay.classList.remove("hidden");
  closeRulesButton.focus();
});

closeRulesButton.addEventListener("click", closeRules);

rulesOverlay.addEventListener("click", (event) => {
  if (event.target === rulesOverlay) {
    closeRules();
  }
});

document.addEventListener("keydown", (event) => {
  if (event.key === "Escape" && !rulesOverlay.classList.contains("hidden")) {
    closeRules();
  }
});

/*
   Hint
*/

hintButton.addEventListener("click", useHint);

/* =========================================================
   INITIAL CHECKLIST
   ========================================================= */

createChecklist();

Enter fullscreen mode Exit fullscreen mode

How I Built It

  • HTML & CSS - User Interface of the gaming application.
  • JavaScript - Game Interactions like hints, time limit, and score.
  • ChatGPT - Code Generation by giving it my ideas
  • Microsoft Copilot - Image Generation of the witch and the game background of the hidden objects

The game loop

The design is a tight loop built on three numbers shown in the HUD:

Element Value Purpose
๐Ÿงช Score 0 / 25 Progress toward winning
โณ Time 60s Pressure
๐Ÿ’ก Hints 5 A limited safety net

With 25 items in 60 seconds, you get roughly 2.4 seconds per ingredient. That pacing is deliberately frantic, which is what makes the forest feel hostile.

Design decisions I'm proud of

  • An ingredients checklist panel (๐Ÿ“œ). Hidden-object games are frustrating when you don't know what you're looking for, so the checklist keeps the player oriented.
  • Hints as a resource, not a rescue. Only 5 hints means you have to decide when to spend them, which turns a simple search game into a small risk-reward decision.
  • A landscape-first layout. A wide forest scene doesn't work in portrait, so a "Rotate Your Device" overlay guides mobile players instead of letting them squint at a cramped scene.
  • A dedicated Rules modal. The game is playable without reading anything, but the rules are one tap away.
  • Atmosphere over assets. A custom logo, a witch character, and a dark forest background do most of the storytelling.

A trade-off I'd revisit

To stop pinch-zoom from interfering with tapping on the scene, the viewport is locked with user-scalable=no. That works for gameplay, but it hurts accessibility for players who rely on zoom. Next, I'd keep the lock only on the game canvas and allow zoom elsewhere.

The open-source AI layer

  • Used ChatGPT and Microsoft Copilot for
  • What it actually does in Portion Quest (e.g., generating witch dialogue, hint text, ingredient lore, or level layouts)
  • It runs in online mode only.

Tech stack

  • Front end: HTML, CSS, JavaScript
  • Hosting: Netlify (static deployment)
  • AI: ChatGPT, Microsoft Copilot

Why Does Open Innovation Matter?

  • Runs only in online mode.
  • Free to play from anywhere around the world.

The Spirit of It

This game boosts our potential to find the hidden objects that are listed in the game with helps to improve our time management skill.


Thanks for playing, and for reading. Did you find all 25? Tell me your best time in the comments. ๐Ÿงช

Top comments (0)