Managing state with Pinia

Let’s now implement our state management system with Pinia.

What is Pinia and why are we using it?

Pinia is a state management library for Vue. With Pinia, you create a place to store your data aptly called a “store”. Your stores have an initial state, getters for retrieving state data, and actions for mutating state data.

If you’d like to learn more about Pinia, I recommend the official documentation.

Inside the src folder you’ll find that create-vue has setup a basic Hello World project for us. Since we opted in to Pinia for state management during the setup process, there is a src/stores/ folder with an example store counter defined in counter.ts.

Let’s rename src/stores/counter.ts to src/stores/gamestate.ts. We’ll use this file to define a Pinia store that represents and manages our game’s state.

Next, delete everything in that file, then import and use defineStore to start things off:

import { defineStore } from 'pinia'

export const gameState = defineStore({
  id: 'gamestate',

Here we begin defining gameState, something we’ll import into other parts of our game engine to get and mutate state.

A Pinia store has, in addition to an id, three main sections:

  • the initial state of the store, called state
  • methods to retrieve values from the store, called getters
  • methods to mutate values from the store, called actions

We’ll define these in order, starting with state:

// Describe the initial state of our store:
state: () => ({
  energy: 0,            // Start at 0 energy
  capacitors: 1,        // Start with 1 capacitor
  circuits: 0,          // Start with 0 circuits
  costMultiplier: 1.35, // Set rate of growth to 1.35
  baseCosts: {
    capacitors: {
      energy: 3        // Capacitors base cost is
    } as Cost,         // 3 energy
    circuits: {
      energy: 5,       // Circuits base cost is
      capacitors: 5    // 5 energy and 5 capacitors
    } as Cost
  } as BaseCosts
} as GameState),

The state property is where we describe our game’s state — the variables we want to keep track of and be able to pass into other parts of our game.

State propertyWhat it’s for
idThe unique identifier for the store
energyThe total amount of energy generated in the game
capacitorsThe number of capacitors purchased; each increase the amount of energy generated per click
circuitsThe number of auto-clickers purchased; each automatically click one time per second per unit purchased

That takes care of our game’s initial state. Our next step is to define some methods to retrieve data from our game’s state manager. For the game we are building, we need to know the following:

  • the total amount of each resource that we’ve either generated or purchased
  • the next cost of something that is able to be purchased
  • whether or not we can afford that cost

Before we define these getters, though, let’s build some helper functions based on the information we know we’ll need.

# Generating the next resource cost

One thing we’ll need to know throughout this game is the cost of the next resource we want to purchase. I’m using the word “resource” loosely here. In your game, you may call it an “upgrade” or “unlockable” or something like that. Either way, the basic premise is the same: I should be able to see the amount of other resources I will need to purchase something.

At the top of src/stores/gamestate.ts, just after the import statement, let’s define a helper function that will calculate the next resource cost of a given resource:

const generateNextCost = (state: any, resource: string): Cost => {
  // Get the base cost for the resource:
  const baseCosts = state.baseCosts[resource];

  // Get how many of these resources we already own:
  const currentResourceCount = state[resource];

  // Iterate over baseCosts to dynamically calculate
  // the next cost for each cost type (e.g., energy,
  // capacitors, circuits). The reduce function helps us
  // accumulate the next cost values into a new `Cost`
  // object that we can then return:
  return Object.keys(baseCosts as BaseCosts).reduce(
    (nextCost: Cost, costKey: string) => {
      // Get the base cost--or, if baseCosts[costKey] is falsy,
      // default the base cost to zero:
      const baseCost = baseCosts[costKey] || 0;

      // Calculate the next cost by multiplying the
      // base cost by the cost multiplier raised to the
      // power of the current resource count, and wrap it
      // in Math.floor() to ensure the result is an integer:
      nextCost[costKey as keyof Cost] = Math.floor(
        baseCost * Math.pow(state.costMultiplier, currentResourceCount),
      );
      return nextCost;
    },
    {},
  );
};

With this function, we’ll be able to pass along a reference to our game’s current state and the string name of something we can buy, then get a Cost object in return containing the cost of that purchasable item:

// For example:
const nextCircuitCost = (state) => generateNextCost(state, "circuits");

The state parameter will come from Pinia; each getter will be passed the current state so we can always get the most current value of everything.

# Checking if player can afford next resource

Knowing the cost of the next purchase with generateNextCost, we can now create a second helper function to return whether we can actually afford it. This second helper function will compare the next cost with the player’s current inventory, then return true or false based on whether the player can afford it:

const canAffordNext =

  // Check if the player can afford the next resource
  // based on the current state:
  (state: GameState, resource: string): boolean => {
    // Generate the cost of the next resource based on the current state:
    const nextCost = generateNextCost(state, resource);

    // Check if player has enough of each cost key:
    return Object.keys(nextCost).every(
      // For every cost key, see if the matching state key
      // is greater than or equal (if yes, then player
      // can afford):
      (costKey) => {
        // Get the current count of the resource,
        // and default to zero if it doesn't exist:
        const currentResourceCount = state[costKey] || 0;

        // Get the cost of the resource,
        // and default to zero if it doesn't exist:
        const cost = nextCost[costKey as keyof Cost] || 0;

        // Return whether the current count is
        // greater than or equal to the cost:
        return currentResourceCount >= cost;
      },
    );
  };

With these two helper functions, we’re now ready to implement the getters for our Pinia store that holds our game’s state.

# Getter functions

Our src/stores/gamestate.ts file has two helper functions followed by the beginning of our Pinia store. The store has the state configured, and we’re now ready to get started on the next section: the getter functions.

Getter functions are used to “get” variables from the state and bring them into our presentation logic — which, for this game, are Vue components.

Just like how the initial state is defined in a state property, getters in a Pinia store are defined in a getters property. So let’s start by creating a getters property that we can put our getters in, along with our first getter:

  } as GameState),

  // Define our getters:
  getters: {
    getEnergy: (state) => state.energy,

Our first getter returns the total amount of energy in our current state. Notice how we provide state as a parameter. In Pinia, getters rely on state, so any getter function should, at a minimum, expect a state.

Since capacitors represent how much energy is generated per click and circuits represent how many clicks are automatically made per second, let’s create a getter for each of those next:

// Define our getters:
getters: {
  getEnergy: (state) => state.energy,
  energyPerClick: (state) => state.capacitors,
  energyPerSecond: (state) => state.circuits,

Now let’s create some getters to provide resource costs.

We can start with a getter that leverages our generateNextCost helper function to use the state and a name of a resource to calculate the next cost of something:

// Define our getters:
getters: {
  getEnergy: (state) => state.energy,
  energyPerClick: (state) => state.capacitors,
  energyPerSecond: (state) => state.circuits,
  nextResourceCost: (state) =>
    (resource:string) => generateNextCost(state, resource),

In the nextResourceCost getter, we pass along the state and also the name of a resource into generateNextCost. While this is a good general-use getter, we can create explicit getters for the resources we already know we’ll need to know the next cost of (i.e., capacitors and circuits) as well:

// Define our getters:
getters: {
  getEnergy: (state) => state.energy,
  energyPerClick: (state) => state.capacitors,
  energyPerSecond: (state) => state.circuits,
  nextResourceCost: (state) =>
    (resource:string) => generateNextCost(state, resource),
  nextCapacitorCost: (state) => generateNextCost(state, "capacitors"),
  nextCircuitCost: (state) => generateNextCost(state, "circuits"),

Just like when we built the helper functions, we not only need to know what the next resource costs are but also whether we can afford the next resource. Let’s build our final getter functions that leverage the other helper function:

// Define our getters:
getters: {
  getEnergy: (state) => state.energy,
  energyPerClick: (state) => state.capacitors,
  energyPerSecond: (state) => state.circuits,
  nextResourceCost: (state) =>
    (resource:string) => generateNextCost(state, resource),
  nextCapacitorCost: (state) => generateNextCost(state, "capacitors"),
  nextCircuitCost: (state) => generateNextCost(state, "circuits"),
  canAffordCircuit: (state) => canAffordNext(state, "circuits"),
  canAffordCapacitor: (state) => canAffordNext(state, "capacitors"),
},

All of these getters will be used when we implement the game’s Vue components (our presentation — or “view” — layer). But we’re not quite ready to move on to our presentation logic just yet!

While getters are used to get data from our game’s state, actions are used to mutate — or change — data in our game’s state. Let’s define some actions that our game components will use to modify state values.

# Action functions

The third and final section of our Pinia store’s definition is its actions. Actions are functions that modify state values.

There are three actions that can happen within our game’s state: energy can be generated, a capacitor can be purchased, and a circuit can be purchased. Each of these actions will have their own separate function in our store’s actions property.

Let’s go ahead and define them all now:

  // Define actions that mutate state values:
  actions: {
    // If amt given, just generate that much energy.
    // Otherwise, assume click
    generateEnergy(amt?:number) {
      if(amt) {
        this.energy += amt
      } else {
        this.energy += this.energyPerClick
      }
    },

    addCircuit() {
      if(this.canAffordCircuit) {
        this.energy -= this.nextResourceCost("circuits")?.energy ?? 0
        this.capacitors -= this.nextResourceCost("circuits")?.capacitors ?? 0
        this.circuits++
      }
    },
    addCapacitor() {
      if(this.canAffordCapacitor) {
        this.energy -= this.nextResourceCost("capacitors")?.energy ?? 0
        this.capacitors++
      }
    }
  }
}) // End of pinia store definition

Notice how actions can call getters by using this, like how addCircuit() first checks if the player can even afford a circuit via this.canAffordCircuit.

Each of these actions are generally related to a button that the player can press, i.e., addCapacitor will be called when the player tries to buy a capacitor and addCircuit will be called when the player tries to buy a circuit. The dual-purpose generateEnergy function will either generate some amount that’s passed as the amt parameter or however much we currently generate per click.

With the state, getters, and actions all defined, our Pinia store is complete — and with it, most of the logic for our game! In the next part, we’ll bring all of this game logic onto the screen.

Checkpoint

Before moving on, you should be able to confidently:

  • Build a Pinia store with initial state, getters, and actions

    the store exposes energy/capacitor/circuit state plus cost and affordability getters

  • Compute purchase costs with a reusable growth formula

    generateNextCost returns higher costs as you own more of a resource

    The growth formula (base * multiplier^owned) is the single biggest lever on game feel. A multiplier near 1.05 gives a long, grindy curve; 1.5+ ramps steeply. Prototype with a few values and play it before you commit to one.