Ko-Wahi Wastes

Published

After playing through MNOG 2 I got curious how exactly the maze works, and if there are any tricks to solving it. In the process I discovered a rare bug and ended up providing a few different patches for the game.

The only prior work I could find is this BZPower post from 2014, which shows the code but didn’t explain it in detail. So I decided to use my computer powers and dig into the code myself :3

How the maze is generated

The code is located in KokoroWastes.swf (scripts, frame 4, DoAction [3]), decompiled with JPEXS, and repeated here, split up into several parts along with an explanation and visual example of what each part does. It defines a createMaze function, which is called once when entering the maze for the first time, and the maze is saved until you close the game.

All code in this section is from “Mata Nui Online Game II: The Final Chronicle” by Templar Studios, LEGO.

Step 1: Setup

unlinkedNodes = [];
var i = 1;
while(i < 10) {
  unlinkedNodes.push({ name: "Scene" + i, direction: "north", link: "" });
  unlinkedNodes.push({ name: "Scene" + i, direction: "south", link: "" });
  unlinkedNodes.push({ name: "Scene" + i, direction: "east",  link: "" });
  unlinkedNodes.push({ name: "Scene" + i, direction: "west",  link: "" });
  i++;
}
finalResults = [];

As a first step, it creates nodes for each of the 4 cardinal directions for every scene. As the loop variable i starts at 1 and excludes 10, it creates 9 scenes, resulting in a list of 36 total nodes. These nodes represent the different directions you can walk in and will be linked to other nodes.

Picture of 3 paper squares with 4 holes each strewn loosely about.
The pictures will show the steps with only 3 scenes for simplicity. Thanks to Korohpu for helping me with the crafts!

Step 2: Main Entrace

startNode = unlinkedNodes[random(unlinkedNodes.length)];
startNode.link = { name: "KokoroWastesEntrance" };
var j = 0;
while(j < unlinkedNodes.length) {
  if(unlinkedNodes[j].name == startNode.name and unlinkedNodes[j].direction == startNode.direction) {
    unlinkedNodes.splice(j, 1);
    j--;
    break;
  }
  j++;
}
finalResults.push(startNode);

Next it picks a random node and links that to the main entrance (KokoroWastesEntrance). This node is then removed from the list of unlinked nodes and added to the list of processed ones.

Picture showing the a drawn main entrance square now connected with a red string to one of the white squares. A Hahli figure is now also here.

Step 3: Beginning the maze

var picking = 1;
while (picking == 1) {
  fromNode = unlinkedNodes[random(unlinkedNodes.length)];
  if (fromNode.name == startNode.name) {
    picking = 0;
  }
}

Now that it has a main entrance, it can start linking different nodes to each other and make a maze! To begin with, it chooses a random “from” node that is on the same scene as the main entrance, and remembers that for later.

Step 4: The Loop™

safetyCount = 0;
while (unlinkedNodes.length > 1) {

With that, it enters the main loop. As long as there is more than 1 unlinked node left, it keeps repeating the following steps:

Loop step 1: Finding a target

  var toNode = unlinkedNodes[random(unlinkedNodes.length)];
  safetyCount++;
  if (safetyCount > 10 and (toNode.name != fromNode.name or toNode.direction != fromNode.direction)) {
    safetyCheck = 1;
  } else {
    safetyCheck = 0;
  }
  if (toNode.name != fromNode.name or safetyCheck == 1) {

It picks a random “to” node. The rest of the main loop, however, is ideally only executed if the “from” and “to” nodes are on different scenes. As a precaution, the safetyCheck allows it to bypass that condition, if it failed to find a suitable node at least ten times. But even then it still ensures that a node is never linked to itself.

Loop step 2: Making the connection

    safetyCount = 0;
    fromNode.link = toNode;
    toNode.link = fromNode;
    finalResults.push(fromNode);
    finalResults.push(toNode);
    var j = 0;
    while (j < unlinkedNodes.length) {
      if (unlinkedNodes[j].name == fromNode.name and unlinkedNodes[j].direction == fromNode.direction) {
        unlinkedNodes.splice(j, 1);
        j--;
      } else if(unlinkedNodes[j].name == toNode.name and unlinkedNodes[j].direction == toNode.direction) {
        unlinkedNodes.splice(j, 1);
        j--;
      }
      j++;
    }

Once it found two nodes it’s happy with, it links them to each other, and removes them from the unlinked list.

Loop step 3: Keep threading

    var picking = 1;
    while (picking == 1) {
      fromNode = unlinkedNodes[random(unlinkedNodes.length)];
      if (fromNode.name == toNode.name) {
        picking = 0;
      }
    }

At the end of the loop, it picks a new random “from” node. One critical detail here, is that this new “from” node must be on the same scene as the previous “to” node.

Visualising the loop

A nice way to visualise this loop is to imagine threading a string through the 9 scenes. Beginning on the scene with the main entrance, going out a random direction, ideally to another scene, entering one direction and leaving another, until all scenes are tightly threaded together. This also ensures that the entire maze is connected and you can always reach every scene.

The red string is beginning to snake it’s way through all the holes of the white squares.The red string has no gone through all except one of the holes on the white squares.

Step 5: First exit

  }
}
lastNode = unlinkedNodes[0];
lastNode.link = { name: "KantaisEntrance" };
finalResults.push(lastNode);

And finally, it closes the main loop. Because it started with an even number of nodes, picked one out as the main entrance, and then linked pairs together, there is guaranteed to be exactly one unlinked node left. This last node is linked to Kantai’s hut (KantaisEntrance), where you can train willpower with the balancing minigame.

Red string connecting the last hole to a drawing of Kantai’s hut.

Step 6: More exits

selectingNode = 1;
while (selectingNode == 1) {
  secondExitNode = finalResults[random(finalResults.length)];
  if (secondExitNode.link != "start" and secondExitNode.link != "exit") {
    selectingNode = 0;
    thirdExitNode = secondExitNode.link;
    secondExitNode.link = { name: "KokoroRuinsApproach" };
    thirdExitNode.link = { name: "KokoroWastesDeadEnd" };
  }
}
return finalResults;

And finally, it actually undoes one of the previous pairs, instead linking one node to the temple (KokoroRuinsApproach), where you get the Crystal of Peace, and the other to the cliffside (KokoroWastesDeadEnd), where you get the Charm of Willpower. As a safeguard it checks and retries if it would undo the link to the main entrance or to Kantai’s hut.

A pair of scissors showing how the string was cut between 2 white squares.The 2 ends of the cut string are now connected to drawings of the temple and the cliffside.

Summary

The maze is generated completely randomly. The exits could be on the same scene as the main entrance, or they could be on the complete other side of the maze. Multiple exits and loops within the maze mean you have to check every direction on every scene. Flags only last 2 minutes, causing scenes you’ve been through before to look like brand new scenes. It’s bad, y’all. :(

Errors in the algorithm

But wait, it gets worse! :)

There is a small chance that step 6 actually splits the maze into two groups, with no connections between them. While highly unlikely, the result would be an unsolvable maze. Two of the four different destinations would be completely unreachable until the maze is regenerated.

A pair of scissors showing the string being cut in such a way that the squares can now be separated into 2 piles.

Errors in the code

But wait, it gets worse! :)

The safeguard from step 6 is actually ineffective because it uses the wrong names (start and exit instead of KokoroWastesEntrance and KantaisEntrance), allowing it to overwrite the main entrance or the exit to Kantai’s hut. The consequences of that depend on where you enter the maze from.

A pair of scissors showing the string being cut directly at entrance, disconnecting it from the rest of the squares.

The cliffside and whichever of the main entrance or Kantai’s hut got overwritten become unlinked from the maze.

Should you enter the maze from a still linked location, nothing immediately obvious happens. The game continues to run normally, but the unlinked locations become unreachable.

Should you enter the maze from one of the two unlinked locations, the result is more severe. The game gets confused, with the only visual tell being Hahli getting placed at the bottom edge of the screen, and trying to take any of the paths freezes the game.

Screenshots of the game freezing (take my word for it)
Screenshot showing Hahli located at the very bottom edge of the screen after entering the show maze with a corrupted main entrance.
Hahli placed unusually close to the bottom edge of the screen after you enter the maze. “Mata Nui Online Game II: The Final Chronicle” by Templar Studios, LEGO.
Screenshot of the loading bar stuck at “Loading Wahi” after trying to move to another screen in the snow maze with a corrupted main entrance.
The point where the game freezes when you try to move to another scene. “Mata Nui Online Game II: The Final Chronicle” by Templar Studios, LEGO.

Either way, savedata doesn’t seem to get corrupted by this. And when you restart the game you are placed in your last location outside the maze and the maze is regenerated, this time probably without any errors.

How likely are the errors

With the help of Korohpu we did some statistical analysis to figure how likely these errors are as the maze gets bigger. Remember that the base game uses 9 scenes.

Plot showing likelihood of broken or split mazes as the number of scenes increase. In the game the maze is broken about 5.5%, and split about 0.35% of the time. Broken mazes are much more likely with fewer scenes, starting out at 25% with 2 scenes, but quickly dropping through 10% with 5 scenes. Split mazes are almost impossible with 2 scenes, then peak at just below 2% with 3 scenes, before gradually declining again.
Chance of each error occuring as the number of scenes in the maze increases. A maze is “broken” if step 6 unlinked one of the exits, and “split” if the algorithm failed to make the maze fully connected.

Thankfully split mazes are very unlikely, but I was very surprised to see how high the chance of a broken maze is. Further dividing by 2, to get the chance the entrance was overridden causing the game to crash upon first entering the maze, comes out to almost 2.8%. I wonder how many players actually had their game crash when they got to the maze.

I also want to take a paragraph to show some of Korohpu’s work. She determined that there are 4066 non-isomorphic graphs a fully connected maze can take. (Two graphs are isomorphic if one can be turned into the other simply by renaming the vertices.) She calculated this by first determining all possible threading paths (around 34.5 million), and then collapsing them into groups of isomorphic graphs.

A couple examples of non-isomorphic graphs representing elementary maze shapes with 9 scenes

Patching the maze

The final goal of this project is to provide alternative mazes. Initially only a version that skips it all together, since I heard and felt how tedious this part of the game is. Then after discovering the errors I wanted a version that just fixed those and kept the original intent. And finally I thought a little evil version might also be fun, as a treat.

Download the version you want and replace the KokoroWastes.swf file in your MNOG 2 installation folder. For convenience there is also the original version (14.1 KB .swf), which I got from the BioMedia Project, in case you want to undo the change. And if you’re curious, you can expand each version to see my maze generation code for them.

Trivial version (12.6 KB .swf). The maze is just a single scene, directly connecting all four locations. No more getting lost.
function createMaze() {
  _root.KokoroWastesMaze = [
    { name: "Scene1", direction: "north", link: { name: "KantaisEntrance" } },
    { name: "Scene1", direction: "south", link: { name: "KokoroWastesEntrance" } },
    { name: "Scene1", direction: "east", link: { name: "KokoroRuinsApproach" } },
    { name: "Scene1", direction: "west", link: { name: "KokoroWastesDeadEnd" } }
  ];
  _root.KokoroWastesFlags = [];
}
Fixed version (14.4 KB .swf). Changes the algorithm to prevent the maze from crashing the game or being unsolvable. Should be about as difficult as the original.
function createMaze() {
  var scenes = [];
  for (var s = 1; s <= 9; s++) {
    scenes.push({
      name: "Scene" + s,
      nodes: [
        "north",
        "south",
        "east",
        "west"
      ]
    });
  }

  var finalResults = [];

  // Scene order doesn't affect the maze yet, so treat as shuffled.

  // First scene has a random node connected to Entrance.
  finalResults.push({
    name: scenes[0].name,
    direction: scenes[0].nodes.splice(random(scenes[0].nodes.length), 1)[0],
    link: { name: "KokoroWastesEntrance" }
  });

  // Last scene has a random node connected to Kantai's Hut.
  finalResults.push({
    name: scenes[scenes.length - 1].name,
    direction: scenes[scenes.length - 1].nodes.splice(random(scenes[scenes.length - 1].nodes.length), 1)[0],
    link: { name: "KantaisEntrance" }
  });

  // Connect each adjacent pair of scenes with random nodes.
  // The first and last scene have one node connected to an exit, and another node connected to its one adjacent scene.
  // All other scenes have two nodes connecting to their two adjacent scenes.
  for (var s = 0; s < scenes.length - 1; s++) {
    var from = scenes[s].nodes.splice(random(scenes[s].nodes.length), 1)[0];
    var to = scenes[s + 1].nodes.splice(random(scenes[s + 1].nodes.length), 1)[0];

    finalResults.push({
      name: scenes[s].name,
      direction: from,
      link: {
        name: scenes[s + 1].name,
        direction: to
      }
    });
    finalResults.push({
      name: scenes[s + 1].name,
      direction: to,
      link: {
        name: scenes[s].name,
        direction: from
      }
    });
  }

  // Pluck out a random scene, and connect a random node to the Ruins.
  var third = scenes.splice(random(scenes.length), 1)[0];
  finalResults.push({
    name: third.name,
    direction: third.nodes.splice(random(third.nodes.length), 1)[0],
    link: { name: "KokoroRuinsApproach" }
  });

  // Pluck out another random scene, and connect a random node to the Dead End.
  var fourth = scenes.splice(random(scenes.length), 1)[0];
  finalResults.push({
    name: fourth.name,
    direction: fourth.nodes.splice(random(fourth.nodes.length), 1)[0],
    link: { name: "KokoroWastesDeadEnd" }
  });

  // The `third` and `fourth` scenes each now only have one unconnected node left.

  // Shuffle the rest of scenes.
  for (var s = 0; s < scenes.length - 1; s++) {
    var i = random(scenes.length - s) + s;
    if (i == s) {
      continue;
    }
    var tmp = scenes[s];
    scenes[s] = scenes[i];
    scenes[i] = tmp;
  }

  // Put the `third` scene at the beginning, and the `fourth` scene at the end.
  scenes.unshift(third);
  scenes.push(fourth);

  // Connect each adjacent pair of scenes with random nodes.
  // The first and last scene (previously `thrird` and `fourth`) have their one remaining node connected to its one adjacent scene.
  // All other scenes have their two remaining nodes connected to their two adjacent scenes.
  for (var s = 0; s < scenes.length - 1; s++) {
    var from = scenes[s].nodes.splice(random(scenes[s].nodes.length), 1)[0];
    var to = scenes[s + 1].nodes.splice(random(scenes[s + 1].nodes.length), 1)[0];

    finalResults.push({
      name: scenes[s].name,
      direction: from,
      link: {
        name: scenes[s + 1].name,
        direction: to
      }
    });
    finalResults.push({
      name: scenes[s + 1].name,
      direction: to,
      link: {
        name: scenes[s].name,
        direction: from
      }
    });
  }

  _root.KokoroWastesMaze = finalResults;
  _root.KokoroWastesFlags = [];
}
Evil version (14.4 KB .swf). Don’t worry, it only has 6 scenes. Have fun! :)
function createMaze() {
  var scenes = [];
  for (var s = 1; s <= 6; s++) {
    scenes.push({
      name: "Scene" + s,
      nodes: [
        "north",
        "south",
        "east",
        "west"
      ]
    });
  }
  var directions = [
    "north",
    "south",
    "east",
    "west"
  ];

  var finalResults = [];

  // Scene order doesn't affect the maze yet, so treat as shuffled.

  // First scene has a random node connected to Entrance.
  finalResults.push({
    name: scenes[0].name,
    direction: scenes[0].nodes.splice(random(scenes[0].nodes.length), 1)[0],
    link: { name: "KokoroWastesEntrance" }
  });

  // Last scene has a random node connected to Kantai's Hut.
  finalResults.push({
    name: scenes[scenes.length - 1].name,
    direction: scenes[scenes.length - 1].nodes.splice(random(scenes[scenes.length - 1].nodes.length), 1)[0],
    link: { name: "KantaisEntrance" }
  });

  // A random `third` scene has a random node connected to the Ruins.
  var third = random(scenes.length - 2) + 1;
  finalResults.push({
    name: scenes[third].name,
    direction: scenes[third].nodes.splice(random(scenes[third].nodes.length), 1)[0],
    link: { name: "KokoroRuinsApproach" }
  });

  // Another random `fourth` scene has a random node connected to the Dead End.
  var fourth = random(scenes.length - 3) + 1;
  if (third == fourth) {
    fourth += 1;
  }
  finalResults.push({
    name: scenes[fourth].name,
    direction: scenes[fourth].nodes.splice(random(scenes[fourth].nodes.length), 1)[0],
    link: { name: "KokoroWastesDeadEnd" }
  });

  // 1. Make a *directed* thread from the first to the last scene.

  for (var s = 0; s < scenes.length - 1; s++) {
    var from = scenes[s].nodes.splice(random(scenes[s].nodes.length), 1)[0];

    finalResults.push({
      name: scenes[s].name,
      direction: from,
      link: {
        name: scenes[s + 1].name,
        direction: directions[random(directions.length)]
      }
    });
  }

  // 2. Flip first and last, and shuffle the other scenes.

  var last = scenes.pop();
  var first = scenes.shift();

  // shuffle rest of scenes
  for (var s = 0; s < scenes.length - 1; s++) {
    var i = random(scenes.length - s) + s;
    if (i == s) {
      continue;
    }
    var tmp = scenes[s];
    scenes[s] = scenes[i];
    scenes[i] = tmp;
  }

  // add back backwards
  scenes.unshift(last);
  scenes.push(first);

  // 3. Make a *directed* thread from the last back to the first scene.
  // This guarantees that there is a loop connecting all scenes, making them all reachable.

  for (var s = 0; s < scenes.length - 1; s++) {
    var from = scenes[s].nodes.splice(random(scenes[s].nodes.length), 1)[0];

    finalResults.push({
      name: scenes[s].name,
      direction: from,
      link: {
        name: scenes[s + 1].name,
        direction: directions[random(directions.length)]
      }
    });
  }

  // 4. Connect all remaining nodes to random locations. :)

  for (var s = 0; s < scenes.length; s++) {
    while (scenes[s].nodes.length) {
      var from = scenes[s].nodes.pop();

      finalResults.push({
        name: scenes[s].name,
        direction: from,
        link: {
          name: scenes[random(scenes.length)].name,
          direction: directions[random(directions.length)]
        }
      });
    }
  }

  _root.KokoroWastesMaze = finalResults;
  _root.KokoroWastesFlags = [];
}
Hahli Lego figure, wearing a messenger bag crafted from a leaf, waving goodbye.