Slidefive

October 2023




Slidefive is currently live on slidefive.net





The premise

The idea was to build a daily word game that felt challenging and relatable.

I initially had the idea in 2022, named it WordGroak, and released it on the Play Store. It has since been taken down although is still visible on some other sites.

There are 5 columns of 7 letters, which can be arranged vertically to form a guess in the center. After guessing, the number of letters that are both correct and in the correct position within the word is displayed. However, no information is given about where those correct letters are within the guessed word.

The goal is to find the single target word. At the end, a score is given out of 100 representing how quickly you did that, and a corresponding medal for fun. I initially guessed that the times would be log-normally distributed, so the score is based on a biased log-normal mapping of the time.

I had experimented with including the number of guesses and total number of incorrect letters across all guesses (the original WordGroak scoring), and combining this with the time in several ways, but I ended up feeling that the time alone was the best metric.


Generating the puzzles

There are 3 main stages to generating the puzzles:

Choosing a target word

Firstly, a list of 5 letter words was obtained from The Stanford Graphbase. I then manually sorted the entire list - offensive and obscure words were moved to the back. This is so that the first ~3500 words are suitable to be a target but any word can be used as a guess. The interface for creating the puzzles is extremely simple, allowing targets to be skipped.

Choosing targets

words = getWords();
int possibleRequirement = 150;
List puzzleStrs = new List();
List totalPossibles = new List();
List previousTargets = new List(); 
for (int i = 0; i < 100; i++)
{
    bool proceed = false;
    string target = "";
    target = words[rng.Next(3561)].ToUpper();
    while (previousTargets.Contains(target))
    {
        target = words[rng.Next(3561)].ToUpper();
    }
    while (!proceed)
    {
        target = words[rng.Next(3561)].ToUpper();
        while (previousTargets.Contains(target))
        {
            target = words[rng.Next(3561)].ToUpper();
        }
        Console.WriteLine(target);
        proceed = Console.ReadKey().Key == ConsoleKey.Enter;
    }
    string[][] puzzle = generatePuzzle(target);
    Console.WriteLine(target);
    int c = 0;
    while (totalPossible(puzzle) < possibleRequirement)
    {
        Console.WriteLine(totalPossible(puzzle));
        c++;
        Console.Title = c.ToString();
        puzzle = generatePuzzle(target);

    }
    string str = generateString(puzzle, target);
    Console.WriteLine(str);
    puzzleStrs.Add($"'{str}', ");
    totalPossibles.Add(totalPossible(puzzle));
    previousTargets.Add(target);
    Console.Title = i.ToString();
}

Insertion

The next stage, in the generatePuzzle function, chooses completely random words from the list to insert into the puzzle. Each of these words needs to have at least 3 entirely new letters, to add more dimension. This results in a roughly 30% increase in total guessable words, compared to simply adding random letters.

Inserting words

string[][] puzzle = new string[5][];
for (int i = 0; i < 5; i++) { puzzle[i] = new string[7]; }
puzzle = appendWord(puzzle, target).Item2;
string toAppend = words[rng.Next(words.Length)].ToUpper();
puzzle = appendWord(puzzle, toAppend).Item2;
for (int i = 0; i < 12; i++)
{
    toAppend = words[rng.Next(words.Length)].ToUpper();

    var appended = appendWord(puzzle, toAppend);
    bool redPossible = redGamePossible(appended.Item2, target.ToUpper());
    int j = 0;
    while ((toAppend[4].ToString() == "S" && rng.NextDouble() <= 0.8) || (i >= 2 && !redPossible) || appended.Item1 < 3)
    {
        toAppend = words[rng.Next(words.Length)].ToUpper();
        appended = appendWord(puzzle, toAppend);
        redPossible = redGamePossible(appendWord(puzzle, toAppend).Item2, target.ToUpper());
        j++;
        if (j > 100)
        {
            break;
        }
    }
    if (appendWord(puzzle, toAppend).Item1 >= 3 || i > 90)
    {
        puzzle = appendWord(puzzle, toAppend).Item2;
    }
}

static (int, string[][]) appendWord(string[][] p, string word)
{
    string[][] puzzle = copyPuzzle(p);
    string[][] oldPuzzle = puzzle;
    int newCount = 0;
    for (int i = 0; i < 5; i++)
    {
        if (!puzzle[i].Contains(word[i].ToString().ToUpper()) && emptyCount(puzzle[i]) == 0)
        {
            return (-1, oldPuzzle);
        }
        else if (!puzzle[i].Contains(word[i].ToString().ToUpper()))
        {
            puzzle[i][7 - emptyCount(puzzle[i])] = word[i].ToString().ToUpper();
            newCount++;
        }
    }
    return (newCount, puzzle);
}

Filling

The final stage is to fill the remaining empty gaps, starting with the most uncommon letters.


The game itself

Displaying the game area

There are many (easier) ways to implement a game canvas in a web format, but I used pure vanilla HTML, CSS and JS as I wanted the website to be as lightweight and easy to debug as possible. I also find the canvas system to be clunky and slow, so I didn't use it. However, this decision left several difficulties to overcome. For example, I had to use the dvh and dvw units in every component of the website, as well as a custom scaling system to even get the grid to fit on the page properly. Practically every element has to also be absolutely positioned.

Some of the styling


:root {
    --spacing: 6.5dvh;
    --downPush: 10dvh;
}

.sf-main-container {
    position: absolute;
    display: flex;
    justify-content: space-evenly;
    left: calc((100dvw - var(--spacing) * 5) / 2);
    top: var(--downPush);
    width: calc(var(--spacing)*5);
    height: 80dvh;
    -webkit-user-select: none;
    -ms-user-select: none;
    user-select: none;
}

.sf-column {
    z-index: 3;
    display: flex;
    flex-direction: column;
    justify-content: center;
    margin: auto;
    height: calc(var(--spacing) * 7);
    width: var(--spacing);
    -webkit-user-select: none;
    -ms-user-select: none;
    user-select: none;
}

.sf-box {
    margin: auto;
    width: 100%;
    height: 14.2857%;
    border-radius: calc(var(--spacing) * 0.2);
    transform: scale(90%);
    transform-origin: center;
    -webkit-user-select: none;
    -ms-user-select: none;
    user-select: none;
    overflow: hidden;
}

    .sf-box.sf-finished.portrait {
        transform: scale(90%) translateY(calc(-42dvh / 0.9));
    }

    .sf-box.sf-finished.landscape {
        transform: scale(90%) translateY(calc(-34dvh / 0.9));
    }

    .sf-box.flash {
        background-color: white !important;
    }


.sf-selection-box {
    position: absolute;
    left: calc((100dvw - var(--spacing) * 5) / 2 - 0.1dvh);
    top: calc((100dvh - var(--spacing)) / 2 + (var(--downPush) - 10dvh) - 0.1dvh);
    width: calc(var(--spacing) * 6.09 + 0.2dvh);
    height: calc(var(--spacing) + 0.2dvh);
    -webkit-user-select: none;
    -ms-user-select: none;
    user-select: none;
    pointer-events: none;
}

    .sf-selection-box::before {
        transition: inherit;
        -webkit-user-select: none;
        -ms-user-select: none;
        user-select: none;
        pointer-events: none;
        z-index: 5;
        position: absolute;
        height: calc(var(--spacing) + 0.2dvh);
        width: calc(var(--spacing) * 6.09 + 0.2dvh);
        content: "";
        border: 0.25dvh solid transparent;
        mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
        mask-composite: exclude;
        background: linear-gradient(90deg, rgba(0,0,0,1) 40%, var(--selection-box-color) 40.1%, var(--selection-box-color) 50%, var(--selection-box-color) 60%, rgba(0,0,0,1) 60.1%) border-box;
        background-color: black;
        border-radius: calc(var(--spacing) * 0.2 * 1.17) calc(var(--spacing) / 2) calc(var(--spacing) / 2) calc(var(--spacing) * 0.2 * 1.17);
        background-position-x: 40dvh;
        background-size: calc(var(--spacing) * 6.09 + 0.2dvh) calc(var(--spacing) + 0.2dvh);
        background-repeat: no-repeat;
        animation: inherit;
    }

    .sf-selection-box.pre-game::before {
        width: calc(var(--spacing) * 5 + 0.2dvh);
        border-radius: calc(var(--spacing) * 0.2 * 1.17 + 0.1dvh);
    }

    .sf-selection-box.sf-finished::before {
        border-color: transparent;
        border-width: calc(var(--spacing) * 0.5 + 0.11dvh);
        border-radius: calc(var(--spacing) * 0.2 * 1.17 + 0.1dvh);
        background-color: #d2d4d6;
        z-index: 3;
        pointer-events: all;
        transition: 0.5s ease-in-out;
    }


    .sf-selection-box.sf-finished.portrait::before {
        transform: translateY(-42dvh);
        width: calc(var(--spacing) * 5 + 0.2dvh);
    }


    .sf-selection-box.sf-finished.landscape::before {
        transform: translateY(-34dvh);
        width: calc(var(--spacing) * 5 + 0.2dvh);
    }


Interaction

It is incredibly important that the interaction with the grid is smooth and intuitive. So, the letters can be pressed, clicked, scrolled, or even the word can be typed on a keyboard to align the arrays.

Sliding

addEventListener('pointerup', (event) => {
    if (isHeld) {
        pointerUp();
    }
});

addEventListener('pointerdown', (event) => {
    mouseYOffset = event.clientY;
    scrollerDown = true;
    scrollerY = e.pageY - slider.offsetTop;
    scrollerTop = slider.scrollTop;
    slider.style.scrollBehavior = "auto";
});

function pointerUp() {
    isHeld = false;
    if (playing && !finished) {
        const top = document.getElementById("a" + heldID).getBoundingClientRect().top;
        const unclamped = Math.round((pxTodvh(top) - firstY) / ySpacing) * ySpacing;
        const transform = "translateY(" + Math.max(Math.min(unclamped, ySpacing * 3), ySpacing * -3) + "dvh";
        update(heldID, Math.max(Math.min(unclamped, ySpacing * 3), ySpacing * -3) / ySpacing + 3);
        document.getElementById("a" + heldID).style.transition = "0.1s ease";
        document.getElementById("a" + heldID).style.transform = transform;
        document.getElementById("a" + heldID).style.webkitTransform = transform;
        document.getElementById("a" + heldID).style.msTransform = transform;
    }
    if (!scrollerDown) return;
    releaseScroll(0.5, 20);
}

addEventListener('pointermove', (event) => {
    mouseX = event.x;
    mouseY = event.y;
    if (isHeld && loaded && playing) {
        const transform = "translateY(" + (pxTodvh(event.clientY) - pxTodvh(mouseYOffset) + startY) + "dvh";
        document.getElementById("a" + heldID).style.transform = transform;
        document.getElementById("a" + heldID).style.webkitTransform = transform;
        document.getElementById("a" + heldID).style.msTransform = transform;
    }
    if (!scrollerDown) return;
    const y = e.pageY - slider.offsetTop;
    const walk = (y - scrollerY);
    slider.scrollTop = scrollerTop - walk;
    velocity = walk - prevWalk;
    lastMovement = new Date();
});

function mouseDown(id) { // Called from onClick in HTML
    if (isHeld) {
        pointerUp();
    }
    if (playing && !finished && !document.getElementById("s" + id).matches(":hover")) {
        isHeld = true;
        heldID = id;
        startY = pxTodvh(document.getElementById("a" + id).getBoundingClientRect().top) - firstY;
        document.getElementById("a" + heldID).style.transition = "none";
    }
}

document.documentElement.addEventListener('wheel', function (e) {
    var delta = e.wheelDelta || -e.deltaY;

    if (playing && !finished) {
        for (let i = 0; i < 5; i++) {
            const aX = document.getElementById("a" + i);
            if (aX.matches(':hover') && !isHeld) {
                let pos = (delta > 0 ? arrayPositions[i] + 1 : delta < 0 ? arrayPositions[i] - 1 : 0);
                pos = Math.max(0, Math.min(6, pos));
                shiftArray(i, pos);
            }
        }
    }
});

let focusedColumn = 0;

addEventListener('keydown', (e) => {
    if (playing && !finished) {
        if (e.keyCode == 13) { //enter
            submit();
            setFocus(0, arrayPositions[0], false, false);
        }
        if (e.keyCode == 8) { //backspace
            setFocus(0, arrayPositions[0], false, true);
        }
        else {
            let index = vals[focusedColumn].indexOf(e.key.toUpperCase());
            if (index != -1) {
                shiftArray(focusedColumn, index);
                setFocus(focusedColumn, index, true, true);
            }
        }
    }
})

function setFocus(x, y, increase, show) {
    focusedColumn = x;
    if (increase) {
        focusedColumn = focusedColumn >= 4 ? 0 : focusedColumn + 1;
    }
    if (show) {
        const focus = document.createElement("div");
        document.getElementById("b" + x + "" + y).appendChild(focus);
        focus.classList.add("focus-indicator");
        setTimeout(() => {
            focus.classList.add("fade");
        }, 100)
        setTimeout(() => {
            focus.remove();
        }, 600)
    }
}

function shiftArray(x, y) {
    arrayPositions[x] = y;
    const transform = "translateY(" + ((6 - arrayPositions[x]) - 3) * ySpacing + "dvh";
    const aX = document.getElementById("a" + x);
    aX.style.transition = "0.1s ease";
    aX.style.transform = transform;
    aX.style.webkitTransform = transform;
    aX.style.msTransform = transform;
    localStorage.setItem("array_positions", arrayPositions.join("/"));
}


Player analysis

After getting people to try the game across a few months, there are clear patterns in daily performance. Firstly, the times seem to be almost perfectly log-normally distributed.

There is also a clear daily trend across players. Some days are difficult, and some are easy. Since each player gets the exact same puzzle each day, simply with randomised y-positions of letters in the arrays, I think I have achieved my goal of making each day feel like a separate and relatable challenge.

The average of all scores of all players is 73.78, which rewards a low bronze medal. The values of μ and σ have been adjusted to achieve this. 65.1% of all ~3000 played games have resulted in a metallic medal, which I feel is fair. 46.7% resulted in a silver or better, 26.5% resulted in a gold or better, and around 1% resulted in the infamous purple medal.

Mapping 

float mapTime(float time)
{
    float mu = 5.5f;
    float sigma = 1.1f;

    float output = (1f - (float)Phi((MathF.Log(time) - mu) / sigma)) * 100f;

    return MathF.Min(output + 1f, 100f);
}

static double phi(double x)
{
    double a1 = 0.254829592;
    double a2 = -0.284496736;
    double a3 = 1.421413741;
    double a4 = -1.453152027;
    double a5 = 1.061405429;
    double p = 0.3275911;

    int sign = x < 0 ? -1 : 1;

    x = Math.Abs(x) / Math.Sqrt(2.0);

    double t = 1.0 / (1.0 + p * x);
    double y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.Exp(-x * x);

    return 0.5 * (1.0 + sign * y);
}