Notes on wilderplace's data model

Saman Bemel Benrud • Feb 01 2019

Today I’m going to give a technical walkthrough of how the game board works in wilderplace. It’s a follow-up post to Architecting a turn based game engine with Redux.

Before I dive into code, an update on the game:

Day 29: where I’m at

I’ve been thinking a lot about demons this week. Demons are the closest thing to “enemies” the game has to offer:

Here’s an animation mock-up from the above sketches:

a spirit leaving a demon

I haven’t started adding art to the game but I’m really close to being ready to do that, since I have a core set of mechanics that feel good. This screen capture showcases some of these mechanics:

  • The symbol represents an a mask that allows the player enter a trance and move through the “spirit world”. The game’s colors invert in the spirit world.
  • The w characters are monsters. The purpose of the game is to remove all the small dots , which represent spirits, from all the monsters.
  • The p characters represent demons. Demons are only visible in the spirit world. Each demon takes a move after the player moves, demons start each trance with a spirit, and they’re programmed to chase after the player and other monsters on the board in order to transfer their spirit.
  • The t characters are trees. While in the spirit world, the player can pass through them but demons cannot.

Adding movement to non-player characters took up most of my time this week. Writing the AI and pathfinding was surprisingly not the hardest part. Making the game’s data model and rendering layer support every possible state that the game could get into now that creatures can move was the hardest part.

Here’s an example of the kind of situation the game now needs to handle:

In the above screen capture, four characters occupy a single space at the same time. Before this week, only one character, plus a special case for the player, could occupy a space.

The game board’s data structure

The source for each level is a config JSON file:

{
  "content": [
    ["<", "0", ".", "."],
    ["d-0", ".", "m-0", ">"],
    [".", ".", ".", "t"],
    ["0", ".", "m", "0", "t"]
  ],
  "overrides": {
    "d-0": { "dialogue": ["There's an odd presence in the forest"] },
    "m-0": { "spirits": ["PLANT"] }
  },
  //...other properties for handling intro/outro sequences, ect.
}

There are two essential properties in each config file:

  • content is a two dimensional array filled with symbols. Each element in the inner array represents a ‘tile’ on the game board. Each tile has a symbol that corresponds to a factory function for generating a particular game object. For example, t returns a tree, d returns a deity, and 0 returns a null, empty space tile.
  • overrides defines overrides for specifics objects on the board.

All game objects are plain objects without methods. Here’s a basic example:

function makeDiety(overrides = {}) {
  return {
    type: OCCUPANT_TYPES.DIETY,
    name: "Forest deity",
    bio: "Divine guardian of the forest",
    traversable: true,
    ...overrides
  };
}

When the game loads a new level, the config file for the board is passed to a buildBoard function:

const charFnMap = {
  d: makeDiety
  // ...
};

function buildBoard(config) {
  const { content, overrides } = config;

  const getObj = char => {
    // Assign code to part of character before the `-`.
    const code = char.split("-")[0];
    // Get the factory function for the character
    const fn = charFnMap[code];
    // Generate the game object
    return fn(overrides[code]);
  };

  return content.map(row => row.map(getObj));
}

The return value of buildBoard has the same structure as the config file, but each character is replaced by an object. This new array is then saved to the game’s state under a board key.

Then, everything else the game needs to know is derived from the board with selector functions. These selectors drive the game’s core logic. Here are some examples of what selectors do:

  • Get a list of the player’s possible moves for player movement.
  • Get a weighted movement graph for demon pathfinding.
  • Get a flat map that React depends on to render the game board.

Individual game objects don’t “do” anything – all the action in the game is driven by an action queue that runs every time the player performs an action. You can read more about how the action queue works in this previous post.

Adding a 3rd dimension

There’s one big wrinkle in the above data model: it doesn’t support movement from one place to another. Early game designs didn’t allow any character movement, and instead tracked the player’s position and status in separate state properties.

To support movement, I added a 3rd dimension to the board. My buildBoard function ended up including some code that looked like this:

// Save occupant to a variable
const occupant = fn(overrides[code]);

if (occupant.type === "ground") {
  // If tile is already ground, return a 1-length array.
  return [occupant];
} else {
  // Otherwise, add ground and return a 2-length array.
  return [makeGround()].concat(occupant);
}

Now, each tile on the board is represented as an array instead of an object and the first position is always ground. Whenever an occupant moves, I can then assume a “ground” object is in position 1, and add and remove other objects to the array as the player and the NPCs move around.

It works pretty well, but it did mean I needed to re-write all the game logic to handle arrays instead of objects, which added a lot of complexity: lookups and assignments are now map and reduce functions.

Pros, cons, and where to go next with the game board

Here’s what I like about how the game board works right now:

  • The gap between the config file and the game’s state object is small, so it’s easy to reason about the game by keeping a model of the config file in my head.
  • It makes unit testing convenient. I can test all my actions and selectors by performing assertions against small test case boards like [['a', '.']].
  • There’s zero duplicate state.

Here are some alternative ideas that I might explore in the future. My main concern right now is that I’m using a lot of derived state, which both hurts performance and leads to more code complexity than is necessary.

I have a hunch that either a flat Map (with positions for keys) or a graph would make for a better source of truth than the 3-dimensional array. Such a change would retain every I like about the current board, but make selector and reducer functions simpler and more performant. I’m not going to make such a change unless I need to, since what I have now is working for me for now.

Alright, that’s all I have for today. I hope you found it interesting. If anyone has any questions for suggestions, hit me up on twitter at @samanbb.