<< Go back to Posts

Making a Maze with a Static Website

As a child, I found some books that you can play. You read one paragraph, and you are asked to take a decision - "Open the door" or "Ring the bell", "Go left" or "Go right". Then, depending on your choice, the story was different. Here, I describe a simple way to generate a static set of pages enabling to play the game.



Setup

Data Structure

The story can be represented as a directed graph:

  • Each page is a node, where the metadata is the chapter text.
  • Each link connect one page to another

There is an example of the corresponding graph of “Manoir de l’Enfer” from Steve Jackson 1:

Implementation

For each node, we need one dedicated page. And to materialize the links, we need buttons to select one option.

One the previous image (and in books), chapters have a given number. So, if you want to cheat or if you are stuck, just open randomly another page.

To prevent that, I suggest creating the page ID from a hash or a random number. For instance, with seed = 42 (to prevent someone from guessing the hash too easily), we generate hashes the following way:

import hashlib

seed = 42
n_pages = 50

dic_hash = {}
for ID in range(n_pages):
    dic_hash[ID] = hashlib.md5((str(seed) + str(ID)).encode()).digest().hex()

Which gives the following IDs:

{
 "0": "b6f0479ae87d244975439c6124592772",
 "1": "e0c641195b27425bb056ac56f8953d24",
 "2": "f85454e8279be180185cac7d243c5eb3",
 "3": "faa9afea49ef2ff029a833cccc778fd0",
 "4": "3c7781a36bcd6cf08c11a970fbe0e2a6",
 "5": "25b2822c2f5a3230abfadd476e8b04c9",
 "6": "6ecbdd6ec859d284dc13885a37ce8d81",
 "7": "18997733ec258a9fcaf239cc55d53363",
 "8": "8d7d8ee069cb0cbbf816bbb65d56947e",
 "9": "75fc093c0ee742f6dddaa13fff98f104",
...
}

Making Edges

If page 1 connects to page 2 and 3, we must create buttons redirecting to these pages. Additionally, we may add text on these buttons (rather than 2 and 3).

My knowledge in javascript and html are quite limited, so I am unable to create a clean button with the correct behavior. To that purpose, I use bokeh which is a python library enabling making html and javascript elements with interaction.

from bokeh.models    import Button, CustomJS
from bokeh.layouts   import row
from bokeh.embed import components

def create_button(label, url):
    """
    Create a button with "label" on it, redirecting to url
    """
    button = Button(label=label,
                    button_type="primary")


    js_callback = CustomJS(args={}, code="""
        window.open("{}", "_self");
        """.format(url))
    button.js_on_click(js_callback)

    return button

def create_button_row(labels, urls, cmp=True):
    """Create multiple buttons

    :param labels: list of string to add on the different buttons
    :param urls: list of urls where the button redirect to
    :param cmp:
      - False: returns a bokeh element
      - True: returns script and div (string to be embedded in html)
    """
    assert(len(labels) == len(urls))

    lst_b = []
    for label, url in zip(labels, urls):
        lst_b.append(create_button(label, url)

    p = row(lst_b)

    if cmp:
        return components(p)

    else:
        return p

To create two buttons, we run:

labels = ["Click A", "Click B"]
urls = ["https://google.com", "https://www.qwant.com/"]

div, script = create_button_row(labels, urls, cmp=True, redirect=True)

with open("test_script.txt", "w") as fp:
    fp.write("<html>\n" + script + "\n</html>")

with open("test_div.txt", "w") as fp:
    fp.write(div)

This elements are stored in the _include folder of jekyll in my case.

It generate this:

Note: it does not open a new tab. Otherwise, you would end up with hundreds of tabs open at the end of the story.


Generating Components

Now that we are able to generate buttons, we just need to assemble everything together.

We need:

  • to generate all the pages
  • to generate all the buttons

Making a Page

A jekyll page will be composed of:

  • The yaml header
  • The text of the story
  • The buttons

and have the hash in its url.

To keep our _include folder clean, scripts and divs will be located at:

_include/maze/test/

Therefore, pages need to have in it:

{% include maze/test/_div.txt %}

{% include maze/test/_script.txt %}

Location of the pages does not matter. They just need to be all in the same folder, as the url is relative.


Data Format

We need to define how to store a story. We propose to use the json format, which is flexible enough and easily manipulated with python.

This is our test file:

{"1": {
    "text": "You are on the first page! Welcome. You have to select where you want to go",
    "next": [["2", "Go to the left"], ["3", "Go to the right"]]
    },
 "2": {
     "text": "You decided to go left, and arrive in front of the forest. What would you do ?",
     "next": [["1", "Go back to the previous place"], ["4", "Enter in the forest"]]
 },
 "3": {
     "text": "You arrive in front of the house. What would you do ?",
     "next": [["5", "Try to open the door"], ["6", "Try to look if there is someone in"]]
},
 "4": {
     "text": "You entered the forest, but unfortunately, you get eaten by a werewolf. The story ends here",
     "next": []
 },
 "5": {
     "text": "The door is unlocked. This is the end of the prototype.",
     "next": []
 },
 "6": {
    "text": "It doesn't seem to be anyone in the house.",
     "next": [["5", "Try to open the door"]]
 }
}

Which corresponds to this graph:

In the outer dictionnary, each entry corresponds to a page, where the key is the page ID. Here, there are 6 pages.

Each page is described by an inner dictionnary, which has two mandatory keys:

  • text: This is the story of the page
  • next: This is the list of links, which can be empty

Each element in the next list is composed of two elements:

  • The ID of the next page;
  • The text to print on the button.

Putting everything together

import json
import os

seed = "42"

SAVE_PATH = "gen/maze/test/"
SAVE_PAGE = "pages/test/"
PATH_header = "my_yaml_header.yaml"

# Load the graph
with open("my_test_graph.json", "r") as fp:
    graph = json.load(fp)

# Generate IDs
dic_hash = {}
    for ID in graph:
        dic_hash[ID] = hashlib.md5(seed + str(ID)).encode()).digest().hex()

# Create button
os.makedirs(SAVE_PATH, exist_ok=True)

for ID, vals in graph.items():
    ID_hash = dic_hash[ID]

    next_LB = list(map(lambda x: x[1], vals["next"]))
    next_ID = list(map(lambda x: dic_hash[x[0]], vals["next"]))

    script, div = create_button_row(next_LB, next_ID)

    with open("{}{}_div.txt".format(SAVE_PATH, ID_hash), "w") as fp:
        fp.write(div)

    with open("{}{}_script.txt".format(SAVE_PATH, ID_hash), "w") as fp:
        fp.write(script)

# Making the pages
os.makedirs(SAVE_PAGE, exist_ok=True)

data_header = None
with open(PATH_header, "r") as fp:
    data_header = fp.read()

for ID, ID_hash in dic_hash.items():
    
    include_div    = "{% include " + book_ID + ID_hash + "_div.txt %}"
    include_script = "{% include " + book_ID + ID_hash + "_script.txt %}"
    


    page = """{}

{}

<center>
{}
</center>

<html>
{}
</html>
    """.format(data_header, graph[ID]["text"], include_div, include_script)

    with open("{}{}.md".format(SAVE_PAGE, ID_hash), "w") as fp:
        fp.write(page)

Running this script (up to some modification to adapt to your case), you get:

gen/
  + maze/
    + test/
      - hash_x_div.txt
      - hash_x_script.txt
      - hash_y_div.txt
      - hash_y_script.txt

pages/
  + test/
    - hash_x.md
    - hash_y.md

You need to move these items in the corresponding jekyll folder:

my_website/
  + _includes/
    + maze/
      + test/
        - hash_x_div.txt
        - hash_x_script.txt
        - hash_y_div.txt
        - hash_y_script.txt
  + my_folder_with_random_name_for_maze/
    + subfolder_if_necessary/
      + test/
        - hash_x.md
        - hash_y.md

You can get access to the prototype Here

Sources

French websites:



>> You can subscribe to my mailing list here for a monthly update. <<