Type of Tic Tac Toe


We routinely write functions which have nicely defined inputs and outputs.

  function putStatus(job:Id, status:'doing' | 'done' | 'revoked'): Result<Job> {
    /* Dense validation logic */
  }

More often that not we then stitch these together with some runtime logic to satisfy our product goals.

But sometimes this can be taken further. Instead of a single output that can be arbitrarily given to the next phase of application processing, or added to a queue, what if our process and it progression was goverend by some rules? The next phases of the system could be encoded in the output so you could only ever call a valid chain of operations.

Consider Tic-tac-toe. It has a definite number of players - namely two, “X” and “O”. We could encode these as types and refer to them collectively as “Player”.

  type X = "X"
  type O = "O"
  type Player =
      | X
      | O

The game is played on a 3x3 board. Here I’ve encoded this as compass directions but you could also encode this as 0-8/1-9. Like Player above, the positions are collectively referred to by the Position type.

  type N  = "N"
  type NE = "NE"
  type E  = "E"
  type SE = "SE"
  type S  = "S"
  type SW = "SW"
  type W  = "W"
  type NW = "NW"

  type Position =
      | N
      | NE
      | E
      | SE
      | S
      | SW
      | W
      | NW

The game is played in turns. During each turn a player puts their representative piece on an unoccupied position of the board.

Naturally the game is represented by a map of postition to player.

  type Board =
      Map<Position, Player>

This where we start to cross from pure problem domain into the solution domain. The State type represents the current state of the game: the board and whos turn it is. Each time a turn is played these will change: the board will update with a new mapping from position to player and the player will change.

type State<Available extends Position, NextPlayer extends Player> =
    | Win<Player>
    | Draw
    | {
        board: Board
        player: NextPlayer
        playTurn: <Target extends Available>(position:Target) => Next<Target,Available,NextPlayer>
      }

The interesting piece of incidental complexity is the playTurn function. Remember that at each step we want to be able to determine the valid set of next steps. At each step the set of available positions will decrease and there may or may not be a winner (ttt can end in a draw if all spaces are taken up).

In order to capture those details we can no longer have a single playTurn function as the types of Position and Player capture the entire set of options. In other words the using that single function the game would never progress because the input set would never be diminishing. Instead what we do is return a new playTurn function describing the new state of events and what positions can be played or even if the game has finished.

  type Next<Target extends Available, Available extends Position, Current extends Player> =
      State<Exclude<Available, Target>, Exclude<Player, Current>>

  function createState<Available extends Position, CurrentPlayer extends Player>(board:Board, player:CurrentPlayer):State<Available,CurrentPlayer> {
      return {
          board,
          player,
          playTurn: <Target extends Available>(position:Target):Next<Target,Available,CurrentPlayer> | Win<Player> | Draw => {

              board.set(position,player)

              if (won(player))
                return Win(player)

              if (draw())
                return Draw()

              let nextPlayer = (player === "O" ? "X" : "O") as Exclude<Player,CurrentPlayer>

              return createState(board, nextPlayer)
          }
      }
  }

The createState type constructor captures this intent. Given the available positions, Available, the playTurn function defines Target as a subset of Available. When the playTurn function is called the next state uses the Target position to define the next set of Available positions, namely, it’s those unplayed positions minus the just played Target position.

Determining the player is trivial, it’s just the exclusion of the current player from the set of all players. In the case of tic-tac-toe there’s only two players so this functions to swap the players each turn.

Interestingly, because we’ve defined the position argument to be an unplayed position, we don’t have to check it before we set it in the board map. In less strict definitions this might require runtime checks for correctness.

For each turn we check to see if they player has won, or all positions have been used up in which case a draw is called. If not the we know the game is still ongoing and a new state comprising a change in player and reduced set of positions to play is created.

As a bit of house-keeping I also use a function to start the game off. It creates the board and sets the first move before calling into the regular createState game loop.

function start<Target extends Position, FirstPlayer extends Player>(position: Target, player:FirstPlayer):Next<Target,Position,FirstPlayer> {
    let board = new Map<Position, Player>
    board.set(position, player)

    let nextPlayer = (player === "O" ? "X" : "O") as Exclude<Player,FirstPlayer>

    return createState(board, nextPlayer)
}

You don’t technically need it but I think it makes the API nicer to use. Also, you don’t technically need the board in the game state. Since it’s handled by the createState function and never directly associated with input it could just be invisibly passed from one game state to the next. In fact, because the current player is automatically enforced by the rules and swaps each turn, it too is superfluous - all you really need is to accept a position argument.

Although somewhat complex, this approach of statically locking down APIs via a state machine is incredibly powerful. It’s only really limited by the applicability of your partciular problem. In this case there are clearly defined rules of progression and the state-space is small enough that we can represent it here. Even if you don’t use a fully-fledged state machine as here, accurate domain modelling and recognising the states of our domain and how the interact/overlap will go a long way to creating more robust, maintainable software.

post-divider

useState Obsession


So often I see components with large useState “clumps” like this

function SomeComponent() {
  // State for
  let [ swatches, showSwatches ]  = useState(false);
  let [ layers,   showLayers ]    = useState(false);
  let [ fonts,    showFonts ]     = useState(false);
  let [ tools,    showTools ]     = useState(false);

  // State to
  let [ stateA,   setStateA ]     = useState(false);
  let [ stateB,   setStateB ]     = useState(false);

  let closeSwatches = () => {
    showSwatches(false);
    showLayers(false);

    // Either tools or swatches *must* be open
    openTools();
  }

  let openTools = () => {
    showTools(true);
    showFonts(false);
  }

  let closeTools = () => {
    openSwatches();
  }

  // On and on for all the handlers of valid sets of panes
  // ...

  return (
    // ...
  )
}

In this example swatches, layers, fonts and tools constitute a logical “clump” - the pieces of state representing whether some associated UI “pane” is rendered. In a regular component there could be many of these logical groups. As well as the state, there’s the handler functions which typically represent the events, they correspond to particular changes in state i.e. when openTools occurs we show the tools pane and hide/collapse the fonts pane.

Impact

As the amount of state and associated handlers grows (of which there could be many; it’s not necessarily 1:1), maintainability, comprehension and cohesion are negatively impacted. Encoding a complex UI arrangement through an ensemble of coordinated useState holding primitive values forces complexity into our handlers and increases the complexity of the component too.

An alternative

A more maintainable approach is using a richer data type. Instead of encoding the data as primitives, use an object

function SomeComponent() {
  let [ panes, setPanes ] = useState({
    swatches: false,
    layers: false,
    fonts:  false,
    tools: false
  });

  let [ stateA,   setStateA ]     = useState(false);
  let [ stateB,   setStateB ]     = useState(false);

  let openTools = () => {
    setPanes(panes => ({
      ...state,
      tools: true,
      fonts: false
    });
  }

  let closeSwatches = () => {
    setPanes(panes => ({
      ...state,
      swatches: false,
      layers: false
    });
  }

  return (
    //...
  )
}

This more accurately captures the intent that these pieces of data should be used in concert as the mechanism for rendering the panes. However, this approach still suffers from the event handlers polluting the component definition.

Information hiding

Furher along the information hiding route, we could use useReducer and it might look something like this –

  // This function is the "reducer". Given some action/message
  // and the current state, it produces a new state
  const OPEN    = true;
  const CLOSED  = false;

  let reducer = (action, state) => {
    switch(action) {
      case 'OPEN_TOOLS': {
        return {
          ...state,
          tools: OPEN,
          fonts: CLOSED
        }
      }
      // remaining state transitions...
    }
  }

  function SomeComponent() {

    let [ { paneA, paneB, paneC, paneD }, dispatch ] = useReducer(reducer);
    let [ stateA, setStateA ] = useState(false);
    let [ stateB, setStateB ] = useState(false);

    return (
      <>
        {/* ... */}
        { tools && <Pane.Tools onOpen={() => dispatch('OPEN_TOOLS')} /> }
        { fonts && <Pane.Fonts onOpen={() => dispatch('OPEN_FONTS')} /> }
        {/* ... */}
      </>
    )
  }

SomeComponent becomes more descriptive; all the details of rules for what panes are opened/closed and the associated mechanisms are tucked away in the reducer function and we communicate via messages defined by that function. I think the intent and comprehension is now significantly clearer.

Wrap up

You may be thinking this is all a load of faff; they’re functionally equivalent, so what’s the point. I would say it’s the same argument against God objects but at a smaller scale. Decomposing and making design decisions in order to minimise local complexity is always going to win out in the long term.

A interesting question to ask would be, why do we get these useState clumps? Of course I’m not exactly sure but my suspicion is that “primitive obsession” dovetails nicely into useState - whether someone only thinks in useState which influences the design of the model, or the model is designed with primitives which are naturally represented with useState is the other question. Though, it’s easy to understand why useState is the state primitive most close at hand (in the Simple Made Easy sense) - it says “state” on the tin. The other one has “reducer” in the title. Pure marketing fail.

If that’s the case maybe a bit of marketing could improve the situation. Instead of useState maybe more appropriate name would be useVar? State implies a set of distinct states with valid transitions between, while “var” doesn’t, it represents a bag for a value and that value may change. If we keep going, useReducer could be renamed to useState or useStates (plural) to convey it use with state transitions.

It’s not ideal but we can do this today with import aliasing –

import { useReducer as useStates, useState as useVar } from 'react'

function SomeOtherComponent(props) {
  let [ state, dispatch:send ] = useStates({ /* state description */ });
  let [ value, setValue ] = useVar("Aviato");
}

Are useState clumps a huge problem? No, probably not. This was more a thought experiment than anything. But more often than not they’re a signal for what I can expect from the remainder of the component and just how much has been invested in getting the data structures right.

post-divider

Building a Better Star Icon


Don’t let the simple exterior fool you, this little star hides a plethora of decisions, trade-offs, tunings. But most importantly, I think it’s more enojyable to use.

Creating the star shape

To start, I wanted to see how much I could implement using only HTML/CSS, no images or fancy fonts and no dependencies. So how could I make the star shape if not using an image or font? I initially reached for the CSS polygon function. The idea being the points trace out a path which you can then use with the CSS clip-path property. Those pixels of the element inside the polygon are visible, while those outside are hidden.

clip-path: polygon(
    50% 0%,
    61% 35%,
    98% 35%,
    68% 57%,
    79% 91%,
    50% 70%,
    21% 91%,
    32% 57%,
    2% 35%,
    39% 35%
);

The major benefit of this approach is that being specified with percentages, the shape is automatically responsive (provided you maintain correct aspect ratio of the containing element).

The drawback however, is that you can’t do rounded corners; the browser connects points by straight lines. In order to stay consistent with the original icon I needed rounded corners. If I wasn’t concerned with having the icon resizable I could have used the path function and called it a day. But in order to have a resizable and rounded star, I would need to define the shape within an SVG.

<svg
  width={0} (a)
  height={0}
  style={{ position: "absolute" }}
  className="icon-star"
  viewBox="0 0 1 1" (b)
>
  <defs>
    <path
      id="star"
      d="M0.4351 0.0426 C0.4571 -0.0142 0.5374 -0.0142 0.5594 0.0426 L0.6428 0.2576 C0.6524 0.282 0.6751 0.2986 0.7012 0.3001 L0.9315 0.3129 C0.9923 0.3163 1.0171 0.3927 0.9699 0.4311 L0.7912 0.5768 C0.7709 0.5934 0.7621 0.6202 0.7688 0.6456 L0.8278 0.8685 C0.8434 0.9274 0.7784 0.9746 0.7272 0.9416 L0.5334 0.8166 C0.5114 0.8024 0.4831 0.8024 0.4611 0.8166 L0.2673 0.9416 C0.2162 0.9746 0.1512 0.9274 0.1668 0.8685 L0.2258 0.6456 C0.2325 0.6202 0.2238 0.5934 0.2035 0.5768 L0.0246 0.4311 C-0.0225 0.3927 0.0023 0.3163 0.0631 0.3129 L0.2934 0.3001 C0.3196 0.2986 0.3423 0.282 0.3519 0.2576 L0.4351 0.0426 Z"
    />
    <clipPath id="star-clip" clipPathUnits="objectBoundingBox"> (c)
      <use href="#star" />
    </clipPath>
  </defs>
</svg>

A couple things to note

Let there be light..

With the path created, in order to see anything, we still need two things - an element…

return (
  <>
    <div className="root" onClick={toggleFavourite}>
      <svg
        width={0}
        height={0}
        style={{ position: "absolute" }}
        className="icon-star"
        viewBox="0 0 1 1"
      >
        <defs>
          <path
            id="star"
            d="M0.4351 0.0426 C0.4571 -0.0142 0.5374 -0.0142 0.5594 0.0426 L0.6428 0.2576 C0.6524 0.282 0.6751 0.2986 0.7012 0.3001 L0.9315 0.3129 C0.9923 0.3163 1.0171 0.3927 0.9699 0.4311 L0.7912 0.5768 C0.7709 0.5934 0.7621 0.6202 0.7688 0.6456 L0.8278 0.8685 C0.8434 0.9274 0.7784 0.9746 0.7272 0.9416 L0.5334 0.8166 C0.5114 0.8024 0.4831 0.8024 0.4611 0.8166 L0.2673 0.9416 C0.2162 0.9746 0.1512 0.9274 0.1668 0.8685 L0.2258 0.6456 C0.2325 0.6202 0.2238 0.5934 0.2035 0.5768 L0.0246 0.4311 C-0.0225 0.3927 0.0023 0.3163 0.0631 0.3129 L0.2934 0.3001 C0.3196 0.2986 0.3423 0.282 0.3519 0.2576 L0.4351 0.0426 Z"
          />
          <clipPath id="star-clip" clipPathUnits="objectBoundingBox">
            <use href="#star" />
          </clipPath>
        </defs>
      </svg>

      {/* Element which will use the above shape as a clip-path */}
      <div className={classes.shape}></div> (a)
    </div>
  </>
);

…and some CSS.

.shape {
    width: 100%;
    height: 100%;
    clip-path: url("#star-clip");
    background-color: var(--colour-default);
}

The clip-path: url("#star-clip") is the star of the show (baddum-tisch). If you’re not familiar, clipping is the technique of demarcating an image, creating two regions: inside the region things are visible; outside the region they’re not. In this case the path specified by the id “star-clip” specifies the visible shape (our rounded star) and everything beyond that to the edges of the border box is not shown.

Adding some interaction

So now we have a star with nicely rounded corners. But what about interaction? That was my initial gripe - there was no hover or transition between states. I wasn’t exactly sure what would do the job so I prototyped a few. First I tried animating the background like a cup filling up; on hover it would “fill” a little, and then when clicked it would animate to the top. The problem with this was the icon size. It was going to be used at a height between 17-21 pixels. This meant when hovered the cursor actually obscured a lot of it and you couldn’t even see the inital animation. I also tried rotation, but even a quarter turn felt too heavy-handed, and also moving such a small target whilst hovering is never a great experience.

I ultimately landed on a radial fill which grows outward from the center.

.fill {
    position: absolute;
    border-radius: 50%;
    width: 0px;
    height: 0px;

    opacity: 0;
    background-color: var(--colour-favourited);
    pointer-events: none;

    &.is-favourite {
        animation: 0.3s ease-out forwards explode;
    }
}

@keyframes explode {
    100% {
        /* This needs to be big enough that it grows */
        width: 105%;
        height: 105%;
        opacity: 1;
    }
}

It think it strikes a nice balance between dynamism and subtelty. After all, this component will be located in a table of potentially hundreds of rows where it could be used 5, 10, 20 times at once - having the star spin for every hover and/or click would quickly get overwhelming.

return (
  <>
    <div className="root" onClick={toggleFavourite}>
      {/* svg elided for brevity */}

      {/* This element controls the overall shape of the icon */}
      <div className={classes.shape}>

        {/* This element handles the interaction animations */}
        <div className={classes.fill}></div>
      </div>
    </div>
  </>
);

The crux of this effect works because the inner element is also constrained by the parent’s clip-path property.

Wrap up

Lastly, I found having the click/hover on the star shape finicky and annoying. It was too easy to miss the shape, which made it frustrating to click. I needed a bigger more uniform hit-area so I changed the hover/click to the root element and added a little padding. What resulted was an app-like icon which everyone knows and loves. A tiny detail and obvious in hindsight but easy to miss when you’re head is in the weeds of CSS easings and SVG view boxes.

It’s easy to go overboard when developing a component in isolation. There’s none of the final setting influencing your decisions and you can easily forget the final setting it will operate it, what size it will be and just what type of flow or operation it will be used in. By contrast, developing it in-situ it would be hard to escape the context of the elements around it and how busy a screen may already be. What took me half an hour to realise that an animation en-masse would be overwhelming might have been apparent within 5secs had I a realistic environment to develop in.

This is the full source, JSX and CSS.

import { useState } from "react";
import "./Star.css";

const Star = ({ dimension }) => {
  let [favourite, setFavourite] = useState(false);

  let classes = {
    shape: (favourite ? ["shape", "is-favourite"] : ["shape"]).join(" "),
    fill: (favourite ? ["fill", "is-favourite"] : ["fill"]).join(" "),
  };

  const toggleFavourite = () => {
    setFavourite((favourite) => !favourite);
  };

  return (
    <>
      <div className="root" onClick={toggleFavourite}>
        <svg
          width={0}
          height={0}
          style={{ position: "absolute" }}
          className="icon-star"
          viewBox="0 0 1 1"
        >
          <defs>
            <path
              id="star"
              d="M0.4351 0.0426 C0.4571 -0.0142 0.5374 -0.0142 0.5594 0.0426 L0.6428 0.2576 C0.6524 0.282 0.6751 0.2986 0.7012 0.3001 L0.9315 0.3129 C0.9923 0.3163 1.0171 0.3927 0.9699 0.4311 L0.7912 0.5768 C0.7709 0.5934 0.7621 0.6202 0.7688 0.6456 L0.8278 0.8685 C0.8434 0.9274 0.7784 0.9746 0.7272 0.9416 L0.5334 0.8166 C0.5114 0.8024 0.4831 0.8024 0.4611 0.8166 L0.2673 0.9416 C0.2162 0.9746 0.1512 0.9274 0.1668 0.8685 L0.2258 0.6456 C0.2325 0.6202 0.2238 0.5934 0.2035 0.5768 L0.0246 0.4311 C-0.0225 0.3927 0.0023 0.3163 0.0631 0.3129 L0.2934 0.3001 C0.3196 0.2986 0.3423 0.282 0.3519 0.2576 L0.4351 0.0426 Z"
            />
            <clipPath id="star-clip" clipPathUnits="objectBoundingBox">
              <use href="#star" />
            </clipPath>
          </defs>
        </svg>
        <div className={classes.shape}>
          <div className={classes.fill}></div>
        </div>
      </div>
    </>
  );
};

export default Star;
/* ----------------------------------------------
    Defines an app icon-like shape, most of which
    is transparent, but serves to increase the
    hit-area of the icon making it easier to hit
    a nod to Fitt's Law
    ------------------------------------------- */
.root {
    --colour-default: oklch(0.95 0.005 91);
    --colour-favourited: oklch(0.85 0.15 91);
    --dimension: 35px;

    position: relative;
    width: var(--dimension);
    height: var(--dimension);

    cursor: pointer;
    display: flex;
    justify-content: center;
    align-items: center;

    border-radius: 9px;
    padding: 6px;
    overflow: hidden;
}

/* ----------------------------------------------
    Defines the star shape based on a SVG embedded
   ------------------------------------------- */
.shape {
    display: flex;
    justify-content: center;
    align-items: center;

    width: 100%;
    height: 100%;
    clip-path: url("#star-clip");

    background-color: var(--colour-default);

    transform-origin: center;
    transition:
        scale 0.2s ease-in-out,
        background-color 0.2s linear;

    &.is-favourite {
        animation: 0.225s ease-out bump;
    }

    &:is(:hover, :active) {
        cursor: pointer;
        background-color: oklch(from var(--colour-default) l 0.05 h);
    }
}

@keyframes bump {
    50% {
        scale: 1.1;
    }

    100% {
        scale: 1;
    }
}

/* ----------------------------------------------
    The background colour for the star when it's
    in the "selected" state. Adds a "bump"
    interaction animation which provides more
    feedback that the click occurred
   ------------------------------------------- */
.fill {
    position: absolute;
    border-radius: 50%;
    width: 0px;
    height: 0px;

    opacity: 0;
    background-color: var(--colour-favourited);
    pointer-events: none;

    &.is-favourite {
        animation: 0.3s ease-out forwards explode;
    }
}

@keyframes explode {
    100% {
        width: 105%;
        height: 105%;
        opacity: 1;
    }
}
post-divider