DEV Community

Toby Parent
Toby Parent

Posted on

Chess Pieces, Inheritance vs Composition

In my last post, I started discussing how this chess project has been an evolution that is allowing me to experiment with things in order to learn different approaches. And that last post was pretty clean and easy, as it was simply re-thinking the HTML and CSS required to create a chessboard.

This time, things are a little less clear. There is no right answer this time. There are many ways to tackle this particular challenge, and we'll work through a few of them to the one I finally went with.

Let me also say, though, I really enjoyed and appreciated the feedback and suggestions last time. This is an evolving and growing project, and your comments really give me some great ideas! I don't claim to be an expert by any stretch, I'm still evolving along with this one.

The Why

We are now looking at the chess pieces themselves, and how best to create both the DOM and the javascript representations of them. An easy option might have been to define a ChessPiece class, and extend that for each one:

class ChessPiece{
  constructor(start){
    this.current = start;
    this.domEl = document.createRange()
      .createContextualFragment(`<div class="chess-piece"></div>`).firstChild;
    this.to = this.to.bind(this);
  }

  to(target){
    this.current = target;
    this.domEl.style.gridArea = target;
  }

  static toXY = ([xLetter,y]) => {
    return {
      x:'ABCDEFGH'.indexOf(xLetter),
      y:Number(y)
    }
  }
}

// One more specific piece, derived from that one
class Knight extends ChessPiece{
  constructor(start){
    super(start);
  }
  to(target){
    if(this.isValidMove(target)){
      super.to(target)
    } else {
      console.log("nope nope nope")
    }
  }
  isValidMove(target){
    const start = ChessPiece.toXY(this.current);
    const end = ChessPiece.toXY(target);
    return ( Math.abs(start.x-end.x)===1 &&
          Math.abs(start.y-end.y)===2 ) ||
        ( Math.abs(start.x-end.x)===2 && 
          Math.abs(start.y-end.y)===1 ) 
  }
}

const bqKnight = new Knight("B0")
// yeah, but he's not just a knight, we need to add
//  some custom classes:
bqKnight.domEl.classList.add("queens","black");
// and now we can use it as we like
bqKnight.to("C2")
console.log(bqKnight)
Enter fullscreen mode Exit fullscreen mode

Now, there is nothing inherently wrong with that approach, classes work pretty well and for something this small I might not think twice. If you look at that code, it has some fun stuff going on - a static method in the ChessPiece to attach it to the constructor itself and not its prototype, the string-to-DOM-node trick I picked up from David Walsh - but it's pretty clean.

We define a class, and we extend that for each unique piece. The biggest change for each piece would be the isValidMove function, as we would be tailoring that.

However, toward the end of that we can see the problem with constructor functions and classes: our constructed Knight is completely exposed. Poor guy is a Knight without armor. All his properties and methods are dangling out for all the world to see, and to change willy-nilly. We simply jammed new classes right in, without so much as a "please-and-thank-you".

There are other issues to using inheritance: descendants are tightly coupled to their ancestors in the prototype chain, making them brittle; javascript doesn't do classes the way a class-based language does (prototypal inheritance is a subtly different route), but by calling them "classes" we give a false sense of understanding.

The "white-box" approach, exposing the entire object, is not the only downside to classes in javascript, but it's a major one. And that alone, for me, is a deal breaker. Let's look at another way.

The How

We can re-use functionality in a couple of ways:

  • In the above example, we use prototypal inheritance to define the ancestors (the "prototype chain").
  • But we can also use composition, to build something that can draw from one or more other objects, consuming the functionality it needs. As we saw above, implementing the inheritance route is pretty easy, but let's see if we can move that to a composed functionality instead.

Rather than using classes at all, we can use a Factory function for each piece. Doing that, we gain the hidden scope of the function and we return an interface object to that scope that defines a closure. It's a closed, private space that remains after the function that called it has returned, keeping those variables it contains accessible by planned lines of communication.

Further, with composition, we can delegate. This means, if we like, we can pull in some other object and tell that to handle some part of our main functionality.

In our case, I'd like the HTML bit to be handled by a delegate. We'll call it, generically, Piece. Here's how the implementation of a Piece factory function might look:

const Piece = (starting) => {
  // both the current position and the domNode
  //  are in a private data, contained in the calling
  //  function's own scope.
  let current = starting;  
  const domNode = document.createRange().createContextualFragment(`<div class="chess-piece"></div>`).firstChild;
  domNode.style.gridArea=starting;

  // Both `domEl` and `to` define our interface.
  //  domEl returns a reference to the piece's DOM,
  //  and to updates the piece's location in the grid.
  let domEl = () => domNode;

  const to = (target) =>{
    current=target;
    domNode.style.gridArea=target;
  }

  // Finally, what we return is an accessor into this
  //  private scope. The internal values can *only* be
  //  affected via these two methods.
  return {
    domEl,
    to
  }
}

// And some functionality we might find handy later.
//  When we calculate the start/end [x,y] values for
//  pieces to be able to determine valid moves. But,
//  by defining it on the Piece, we get this automatically
//  when we include it as our delegate.
Piece.toXY = ([xLetter,y]) => {
  return {
    x:'ABCDEFGH'.indexOf(xLetter),
    y:Number(y)
  }
}

export default Piece; 
Enter fullscreen mode Exit fullscreen mode

Now that's great - we have all our DOM manipulation of the piece contained, and we can simply call myPiece.to("C3") to update it in the DOM. I like it!

Another aspect of composition is the re-use and abstracting of functionality, making things useful in other settings. The moves available to chess pieces is a great example: some move laterally any number of spaces, some diagonally; some move many spaces, some only one. But there are a few ways we could simplify those move options.

First, we need to think about moves a little differently. Up to now, our chessboard grid is defined by chess notation: "A8", "D3" and the like. But the rules to moving are (x, y) based. that's why I added that Piece.toXY function - given a "D3", that function gives back a {x:3, y:3} pair. Given a starting and ending point, we'll get two (x, y) coordinates back.

So as to the possible moves, there are four generic rules we need to define:

  • Lateral: start.x === end.x or start.y===end.y (either the x or the y coordinate stays the same for lateral movement).
  • Diagonal: The absolute value of (start.x-end.x) is equal to the absolute value of (start.y-end.y).
  • xByN: Given a number N, the absolute value of (start.x-end-x) must be equal to N.
  • yByN: Given a number N, the absolute value of (start.x-end-x) must be equal to N.

That's it. A rook's move is lateral, a bishop's diagonal. A queen is either lateral or diagonal. A knight is either xByTwo and yByOne, or xByOne and yByTwo.

The pawn is the only tricky one, with different opening (one or two xByOne), movement only in one direction unless capturing, en passant, pawn promotion... honestly, I haven't even begun to think about any of that. Further, the rules I've defined don't take into account whether a piece is in the path or not - this was a simple experiment to see if I could fathom composition enough to implement the simpler aspects of it.

So all that said, moves is a simple object literal. Here's the moves.js:

const moves = {
  // in each, I deconstruct the x and y for 
  //   both start and end, to make it easier to follow.
  lateral: ({x:x1, y:y1}) =>
    ({x:x2, y:y2}) =>
      x1===x2||y1===y2,
  diagonal: ({x:x1, y:y1}) =>
    ({x:x2, y:y2}) =>
      Math.abs(x2-x1)===Math.abs(y2-y1),
  // in the byN rules, we use currying to pass the 
  //  allowed distance as the first parameter.
  xByN: (num) => 
    ({x:x1, y:y1}) =>
      ({x:x2, y:y2}) => 
        Math.abs(x1-x2)===num,
  yByN: (num) =>
    ({x:x1, y:y1}) =>
      ({x:x2, y:y2}) => 
        Math.abs(y1-y2)===num
};

export default moves;
Enter fullscreen mode Exit fullscreen mode

With that, we've defined all our possible moves. We can make them more detailed when we implement them, as with the Knight.js:

import moves from "./moves.js";
import Piece from './Piece.js';

const Knight = (...classNames) => (starting) => {
  let current = starting;
  // here's our DOM delegate...
  const piece = Piece(starting);
  const domNode = piece.domEl();
  // and internally, we can modify the content of that
  //  DOM node. We haven't broken the connection to Piece,
  //  we simply add classes to that original.
  domNode.classList.add("knight",...classNames)

  const isValidMove = (target) => {
    // we can use that static method to get {x, y} pairs
    const start = Piece.toXY(current);
    const end = Piece.toXY(target);

    // composed move functions. 
    // the move function itself is defined by xByN(1),
    //  and when the start and end parameters are passed,
    //  we will get a true or false for each move method.
    const move1X = moves.xByN(1)(start)(end);
    const move1Y = moves.yByN(1)(start)(end);
    const move2X = moves.xByN(2)(start)(end);
    const move2Y = moves.yByN(2)(start)(end);
    // in order to be valid, one of the two pairs
    //   must be valid
    return (move1X && move2Y) || (move2X && move1Y);
  } 

  const to = (target)=>{
    if(isValidMove(target)){
      // we need to keep this current, as isValidMove
      //  uses it.
      current = target;
      // And then we delegate the DOM update to Piece
      piece.to(target)
    } else {
      console.log("Nope nope nope!")
    }
  }
  // And this is simply a passthrough function:
  //  it exposes the piece's DOM node for consumption.
  const domEl = () => piece.domEl()

  return {
    to,
    isValidMove,
    domEl
  }
}

export default Knight;
Enter fullscreen mode Exit fullscreen mode

Note that, in each Factory, I am not exposing any internal variables at all. I expose an interface, which will allow me to communicate with the Knight in a predefined, normalized way:

const bqKnight = Knight("queens","black")("B1");
bqKnight.to("C3");
Enter fullscreen mode Exit fullscreen mode

It works quite nicely, and it hides away the data while exposing the functionality. I will admit, though, that I'm bothered by the repetition - each of the individual pieces share a lot of the same functionality. The only thing that is changing within each piece is its own isValidMove function, but I can't for the life of me figure how to create a re-usable to function, like the one in the Knight above. It should be possible, in fact should be trivial - that's the point of object composition!

Anybody got suggestions on that one?

Oldest comments (0)