February 20, 2021 - Projects

Building a progress tracker using React JS, Redux Toolkit and Firebase

An overview of a progress tracker I built for the popular first person shooter video game Escape from Tarkov.

Screenshot of Tarkov Hideout website

Escape from Tarkov is a realistic and hardcore first-person shooter created by Battle State Games out of Russia. The main aim of the game is to enter the game, work your way across the map, scavenge for items from ak47's to duct tape, not get killed by the real or AI enemies, and extract with your loot. You then use this loot to complete quests, sell on the online marketplace or in-game traders for in-game currency (Rubles), or upgrade your hideout.

The hideout is a complex part of the game comprising of finding dozens of different items. That's where the inspiration came from for building this website/tool. Originally I had used a Google Doc created by a Reddit user to help me track my progress but I felt I could create something that made this a lot less tedious and maybe even a little fun.

The tech stack I decided to utilize for this project was React JS for the front-end and Firebase for the back-end using Firestore NoSQL DB, Cloud Functions with Node JS, and Express and Firebase Auth. My go-to framework for building websites is Gatsby JS but because SEO wasn't important for this project I decided to use vanilla React JS. Firebase makes building applications with a back-end data source and user management a breeze. For state management, I decided to implement Redux which can be overkill but enjoy how it structures my applications making them much more organized (I am using Redux Toolkit in this project).

So what is it all about? How does it work? Here is the website:

https://tarkov-hideout.com

Take a look and click some of the filters, checkboxes, and buttons.

Starting from the top we have a regular header that includes a registered user count, the current version of the game which the tracker is up to date with, and login/sign-up buttons.

Next, we have some tabs that filter the content below based on the category chosen. You'll notice there is a priority tab, this was a neat little feature I added where a user can add a specific item(s) to a priority list which they can view (on a separate screen, phone) whilst playing the game, this simply helps you keep track of multiple items so you don't have to memorize what you're looking for.

Next, we have a simple percentage bar that uses the formula of modules complete / number of modules. As you mark items as complete and scroll you get another circular percentage bar on the bottom left of the screen.

Next, we have the individual items that need to be found in the game separated by different categories including hardware, electronics, medical, and valuables. It shows the item name, a have/need count, and a checkbox that toggles the priority feature I just mentioned. Try clicking some checkboxes and filtering to priority only. You'll see the items populated in this tab.

Next, we have the individual modules that need to be completed in the hideout and these are in alphabetical and numerical order. Each module lists the individual item requirements with checkboxes that will mark that specific item as complete, other requirements that include other necessary modules and in-game trader loyalty levels, there is no user interaction with these parts, and is purely informational. At the bottom of each module, you will find two buttons... A mark complete button that automatically checks each item requirement checkbox and a prioritize module that automatically checks the priority checkbox for each item at the top. Two simple time-saving UX features reduce the amount of clicking required (there are about 267 checkboxes in total).

Because there are so many items and modules I included an ever-useful back-to-top button on the bottom right of the screen.

The data for this application is stored in both local storage in the user's browser if they don't have an account and in Firebase, if they do have a user account. The main benefit of signing up for an account is to persist your hideout data across devices.

So let's take a look at some of the code in this application, this post isn't meant as a tutorial more of a case study in how I built this tool. The following snippets are functions of the app that I either found interesting enough to share or something I was particularly happy with after figuring out the logic.

Here is what the sample JS object looks like for the hideout data:

{
    electronic_items: [],
    hardware_items: [],
    medical_items: [],
    valuable_items: [],
    modules: [],
    percentage: 0,
    hideout_version: "12.9",
    version: "Standard Edition",
}

Each of the *_items arrays will include objects that will look similar to this:

{
    item: "Capacitors",
    priority: false,
    remaining: 12,
    total: 12,
}

Each of the modules in the modules array will include objects that will look similar to this:

{
  module: "Air Filtering Unit",
  level: 1,
  complete: true,
  item_requirments: [{
	need: 10000,
	complete: true,
	category: "valuable_items",
	item: "USD",
	have: 0
    }],
  module_requirments: [{
	item: "Generator, 3"
    }, {
 	item: "Vents, 3"
    }],
  loyalty_requirments: [{
	item: "Skier, 3"
    }],
  skill_requirments: [],
}

So that's how the data is structured, let's look at some of the regularly used functions of the app...

Adding an item to the priority queue

React

{
  hideout.hardware_items.map((item, index) => (
    <li className={styles.item} key={index}>
      <span className={styles.item__name}>{item.item}</span>
      <span className={styles.count}>
        {item.total - item.remaining} / {item.total}
      </span>
      <input
        className={`${styles.item__priority}`}
        type="checkbox"
        title="Prioritize this item"
        checked={item.priority}
        onClick={(evt) => {
          const checked = evt.target.checked;
          const string = "hardware_items";
          dispatch(updatePriority({ index, item, checked, string }));
        }}
      />
    </li>
  ));
}

Redux Reducer function

reducers: {
    updatePriority: (state, {
	payload: {
		string,
		index,
		item,
		checked
	}
    }) => {
	// upating the individual item priority based on the checked value      
	state.hideout[string][index].priority = checked;
    },
}

Marking a module as complete

React

// Parent Compent

hideout.modules.map((mod, index) => (
    <Module key={index} mod={mod} moduleIndex={index} />
));

// Child Component

<button
  className="buttonLink"
  onClick={() => {
    dispatch(
      markModuleComplete({
        moduleIndex,
        mod,
        module,
        level,
      })
    );
    dispatch(setPercentage());
  }}
>
  {mod.complete ? "Reset Module" : " Mark Complete"}
</button>

Redux Reducer function

reducers: {
  markModuleComplete: (
    state,
    { payload: { moduleIndex, mod, module, level } }
  ) => {
    const items = _.uniq(_.map(mod.item_requirments, "item"));
    // Set module as complete
    state.hideout.modules[moduleIndex].complete = !state.hideout.modules[
      moduleIndex
    ].complete;
    // Set all items for module as complete
    state.hideout.modules[moduleIndex].item_requirments.forEach((item) => {
      item.complete = !item.complete;
    });
    mod.item_requirments.forEach((item) => {
      let currItem = item.item;
      let amount = item.need;
      let category = item.category;
      let complete = !item.complete;
      state.hideout[category].forEach((item) => {
        if (item.item === currItem) {
          // if complete, subtract, else add
          if (complete) {
            item.remaining -= amount;
          } else {
            item.remaining += amount;
          }
        }
      });
    });
  };
}

Setting the percentage after marking module as complete

Redux Reducer function

reducers: {
    setPercentage: (state) => {
        const modules = state.hideout.modules;
        const total = state.hideout.modules.length;
        let count = 0;
        modules.forEach((module) => {
            if (module.complete) {
                count++;
            }
        });
    state.hideout.percentage = parseInt(((count / total) * 100).toFixed());
  };
}

Setting storage both local and Firestore

React Code

const dispatch = useDispatch();
const { user } = useSelector(selectUser);
let authenticated;
useEffect(() => {
  // Figuring out if we have a user logged in or not
  if (user) {
    const decodedToken = jwtDecode(user.token);
    if (decodedToken.exp * 1000 < Date.now()) {
      authenticated = false;
      localStorage.removeItem("user");
      localStorage.removeItem("hideout");
      localStorage.removeItem("count");
      window.location.reload(false);
    } else {
      dispatch(setPercentage());
      authenticated = true;
    }
  }
  !authenticated ? dispatch(getInitialHideout()) : false;
  dispatch(getUser());
  dispatch(getCount());
  // Setting up an interval timer to update local and Firestore
  // (if there are changes) every 5 seconds instead of everytime
  // a user clicks something in the app.
  const interval = setInterval(() => {
    dispatch(setStorgage({ authenticated }));
  }, 5000);
  return () => clearInterval(interval);
}, [dispatch]);

Redux code

export const setStorgage = ({ authenticated }) => async (
  dispatch,
  getState
) => {
  // Setting up some variables
  const isRoot = location.pathname == "/";
  const { hideout, user } = getState();
  const prevHideout = hideout.prevHideout;
  const updatedHideout = hideout.hideout;
  // Checking if the hideout has changed since interval
  const hasChanged = !_.isEqual(prevHideout, updatedHideout);
  // First we update local storage
  if (hasChanged) {
    dispatch(setPrevHideout(updatedHideout));
    localStorage.setItem("hideout", JSON.stringify(updatedHideout));
  }
  // If the user is logged in we update Firestore
  if (isRoot && authenticated && hasChanged) {
    const { token, userId } = user.user;
    const options = {
      headers: { Authorization: `Bearer ${token}` },
    };
    await axios.post(
      `https://*******.cloudfunctions.net/api/hideout/${userId}`,
      updatedHideout,
      options
    );
  }
};

Node / Express JS cloud function

// Using firebaseAuth middleware to verify the user
app.post("/hideout/:userId", firebaseAuth, updateUserHideout);

exports.updateUserHideout = async (req, res) => {
  try {
    const hideout = await db.doc(`/hideouts/${req.params.userId}`);
    await hideout.update({ ...req.body });
    return res.status(200).json({ message: "Hideout succesfully updated" });
  } catch (err) {
    return res
      .status(500)
      .json({ error: err, erroMessage: "Opps, something went wrong!" });
  }
};

So that's a quick look into how I built just a piece of my Escape from Tarkov hideout tracker using React, Firebase and Redux! This app is hosted on Netlify.

0 Comments
Leave a comment
Built with Gatsby JS, Contentful & Netlify © 2021