Matthias Dittgen

March 25, 2022

Block Puzzle

Again, some time went by without another blog post. So I won’t put you off with something small. Instead this is going to be some bigger tutorial. We are going to (re)build a casual game with Svelte. How’s that?

Casual Games»

Recently, I was busy trying out some game ideas / concepts I had in my mind. But those are not ready to show off, yet.

At the moment it’s fashionable to make clones of Wordle or tutorials on how to write your own Wordle clone, surely there’s more then one done with Svelte, too. On a side note, I like the clones that recombine the Mastermind concept behind wordle with things other than words, e.g. like Mathler does with algebraic calculations.

But in this tutorial I’ll show you how easy it is to build this well known block puzzle game using Svelte. It’s already two years I encoutered Svelte and back then I did this Copycat when I went my first steps with Svelte.

Your record: 0
Your points: 0

Tl;dr – Let’s play»

Too Long, Didn’t Read.
Please, let me just play the game, now!

Oh ok, here you go! As we are going to build this game, you should know, how it feels.

So start off and drag the three parts below the playing field and place them inside the field. Try to fill lines, either in horizontal or vertical directions and see full lines disappear.

How long can you fit in more tiles?


Puzzle tiles»

So the game is obviously a puzzle game. You have to find a free spot for each of the three random puzzle tiles available for drag and drop. If you can place all three, you’ll get another three to repeat. If a horizontal or vertical line is completely filled with squares, these lines disapear. You get points for alle squares placed as well as all squares that disappear. If you can’t place any tile left, the game is over and you habe to start again.

Tiles»

There are 19 different tiles. So I first thought how to define these tiles? And for that I decided to use an array of strings, each string being a multi-line text tile definition.

You can take a look at the source code of

here. The most important method is getRandomTile() to get one of those nineteen tiles. Each tile exists infinitely often and appears with approximately the same probability. This is perfectly adequate for the game, I think.


Data structure»

While multiline text was handy for tile creation, another format makes more sense for later requirements of the game. A two-dimensional array might be the obvious choice, but I found a single or flat array along with some meta information to be more appropriate. And once decided, I followed that path until the end. The width would be completely sufficient as meta information, if you look at the code you will see that the height is never needed as it is implicit.

Conversion»

Here you can use the slider to view all 19 parts and their representation as the desired structure supplemented by the variant as a 2D-Array and the rendered result. We will talk about rendering a bit later. At the moment we need to convert text into data.

tile: 5 tile: 5
const tile = `
###
  #
  #`;
 
 
 
const field = {
  "size": {
    "width": 3,
    "height": 3
  },
  "values": [1,1,1,0,0,1,0,0,1]
};

const field2d = [
  [1,0,0],
  [1,0,0],
  [1,1,1]
];
 
 

For the conversion, it’s best to have a look at the convertTextToField(text) method from the

file, it is straight forward so I won’t explain it in detail. I recommend grouping methods in files according to their semantics.

So up until now we had tiles, the tile definition and random selection, and now conversion, the transformation of a multiline string tile into our data structure. I would call it a tile field. Everything from now on is fields.

Game logic»

Before we think about how to render tile fields with Svelte, we can have a look at

. The file is about all operations that affect one or more fields. We’ll take a look at some methods from this file, you can skip to Rendering otherwise.

Create fields»

This starts quite simple, e.g. with the methods to create a rectangular or in particular a square field. We can recognize the data structure within the method again. The value can be 1 for set or 0 for and empty squares.

export const createField = (columns, rows, value) =>
  ({
      size: {
          width: columns,
          height: rows
      },
      values: nums(columns*rows).fill(value)
  });

export const createSquareField = (size, value) =>
  createField(size, size, value);

Coordinates»

Of course, each value in our one-dimensional array has an index and thus represents a coordinate within the field. So we have little helpers to convert coordinates into an index or vice versa an index into coordinates. As already mentioned, we need the width meta information for this, but not the height.

const coordsAndSizeToIndex =
  (coords, size) => coords.y * size.width + coords.x;

const indexAndSizeToCoords = (index, size) => ({
    x: index % size.width,
    y: index / size.width >>> 0
});

Manipulate fields»

When a user places a tile field on the playing field we require the addTileToField(tile, field, offset).

There we go through all the elements of our flat array data structure of the tile field. For all values not equal to zero we determine the coordinates, add the offset to position the tile in the field and finally set the value in the field at the calculated index.

export const addTileToField = (tile, field, offset) => {
    tile.values.forEach( (value, index) => {
        if (value !== 0) {
            const coords = plus(indexAndSizeToCoords(index, tile.size), offset);
            field.values[coordsAndSizeToIndex(coords, field.size)] = value;
        }
    });
    return field;
};

And when a line of squares is all set disappears we need the counterpart method removeTileFromField(tile, field, offset). You can see them in the source code linked above.

I tried to write all methods in the spirit of functional programming. By that I mean, they are pure function without side effects, which always return the same result for the same parameters. In this case each time a new field data structure is returned.

Pre-check»

However, the previous mention methods addTileToField(tile, field, offset) does not check whether the tile field still fits into the playing field at the given point. For this we have tileFitsIntoFieldWithOffset(tile, field, offset). Don’t worry, I won’t explain each method line by line.

And then there’s also more general method tileFitsIntoField(tile, field) which checks whether the tile can be placed at any coordinate or index in the field. So we can detect, if a tile fits into the empty spaces of the playing field at all or if the game is already over.

More calculations»

Then again we have helpers to cut lines (rows or columns) from a field with getColumn(field, column) or getRow(field, row). The methods also return a field.

And these methods are used by getFullLines(field) which itself returns a new field. It first goes through the passed field row by row and in the second step column by column. When a line is fully set, it is added to the result field. It detects all completely filled rows or columns and combines them into a new field data structure. This allows us to remove them later all together, e.g. with the help of the remoteTileFromField that we already discussed.

export const getFullLines = (field) => {
    let linesField = createField(field.size.width, field.size.height, 0);
    nums(field.size.height).forEach(r => {
        const row = getRow(field, r);
        if (row.values.reduce(and, 1)===1) {
            addTileToField(row, linesField, {x:0, y:r});
        }
    });
    nums(field.size.width).forEach(c => {
        const column = getColumn(field, c);
        if (column.values.reduce(and, 1)===1) {
            addTileToField(column, linesField, {x:c, y:0});
        }
    });
    return linesField;
};

The method numSquares(field) calculates the number of all set squares or positions of a field. With this we can determine the points that a field is worth when adding or removing.

As a bonus convertFieldTo2dArray(field) is the method to convert our data model, the flat array, into a 2D-Array as shown above, which I initially decided against.

Preparation»

And then we have the transformation of our data model into a form that makes rendering easier, a perfect transition to the next section about rendering. The fieldForView(field) method enriches the values with an index and the 2D coordinates.

export const fieldForView = field => field.values.map((value, index) => ({
	index: index,
	value: value,
	coords: indexAndSizeToCoords(index, field.size)
}));

Rendering»

Now we come to the wonderful world of Svelte Components. Svelte mainly takes care of two things for us, the two-way data binding and the reactivity. So a component changes according to their properties and it can also do some re-calculations without boilerplate code.

Squares»

The smallest pieces that tile fields are build upon are squares. A single

component has coordinates (x|y) as there properties. Properties are exported with let export varName and can be set from outside.

<script>
	export let x = 0;
	export let y = 0;
	export let bg = false;
</script>

<div class={bg?"bg":""} style="left: {4+x*34}px; top: {4+y*34}px"></div>

<style>/* see full source file for styles */</style>

I came up with the idea to misuse the square and also draw the grid out of squares, but render the squares in a different way. To distinguish the styles of the square, we have another property callled bg.

By the way, this is the code needed to draw the squares on the right.

  <Square x="1" y="1" />
  <Square x="3" y="1" bg="true" />
  <Square x="5" y="1" />
  <Square x="5" y="1" bg="true" />

Some decision were quickly made by myself back then, one of them was to go with fixed pixel sizes of the square which might be a drawback when it scomes to portability to mobile devices and stuff like that.

Fields»

The next larger unit is then the Field component. The source is just as simple as the square if not easier. It’s just a for each loop over all items from our field datastructure prepared by fieldForView(field).

You can have a look at the source code of

here. And here’s how to use the component. It can represent either a single tile field, the sum of the empty playing field and the tile fields placed upon as well as the grid field used as background.

  <script>
    const gridField = createSquareField(5, 1);

    const tileField = getRandomTileField();
    let field = createSquareField(5, 0);
    field = addTileToField(tileField, field, point(1,1));
  </script>

  <Field field={gridField} bg={true} />
  <Field field={field} />

Dragfield»

In Svelte there’s no component inheritance, which is an advantage. So we go with composition. This means to make a field component draggable, we compose a dragfield component which conists an instance of the field component. We can easily pass down the {field} property.

The

component has a very simple Markup.

<div
  class="{spotClass}"
  style="{spotPosition} {position}"
  on:touchstart={touchStartHandler}
  on:mousedown|preventDefault={mouseDownHandler}
>
  <Field {field} />
</div>

The more complicated parts of the file are about event listener registration and the positioning. On drag start, we lift the tile visually above the cursor or your finger when talking about touch devices, so the user can still see what tile he’s currently handling.

We register drag move and drag end events, not using Svelte`s on:mouseup / on:touchend event syntax. I’ll dedicate another blog post why that. We also do some coordinate fixing to the position of the playing field at the screen.

And finally we fire a custom drop event to be handled in our main application.

If we look ahead at how the Dragfield component is used, we see that it has also a spot property in addition to field, which indicates the actual number of the three random tiles we are dealing with.

  {#each randomTiles as tile, index}
    {#if tile}
      <DragField spot={index} field={tile} on:drop={dropHandler}></DragField>
    {/if}
  {/each}

Main»

The code above is already part of the

component. It is read straight forward, but has a bigger part of logic directly inside of the component source code. But I encourage to start reading the Markup first. We have a surrounding container, whose reference is put into a Svelte store for our coordinate fixes in Dragfield.

<script>
    import { game } from './stores';
</script>

<div class="game" bind:this={$game}>
</div>

Next we split the game into a lot of areas with a dark brown background color. All the {values} magically update when needed, the already mentioned two way data binding. Our <Field> component will also update as soon as there are changes to {field}.

<div class="area area-points">Your record: <strong>{record !== null ? record : 0}</strong></div>
<div class="area area-points">Your points: <strong>{points}</strong></div>

<div class="area area-field">
  <Field field={bgField} bg={true}></Field>
  <Field field={field}></Field>
</div>

{#each a123 as item, spot}
  <div id="spot{spot}" class="area area-tile"></div>
{/each}

The part about the three random tiles which become rendered as <DragField> components was shown a little further up. Here’s the part of the logic, a method to fill up with three random tile spots again and again. Remember, Svelte is taking care to update the UI whenever the content of randomTiles changes.

The last method randomTileLeft() detects, whether we need to come up with three new tiles.

let randomTiles = [];

const getRandomTileField = () =>
  convertTextToField(getRandomTile());

const getThreeNewRandomTilesFields = () =>
  randomTiles = a123.map(getRandomTileField);

getThreeNewRandomTilesFields();

const randomTileLeft = () => randomTiles.reduce((ret, tile) => tile!==false || ret, false);

Then we have some more game logic, a method to remove all filled up lines. Please notice the points you get for all disappearing squares.

const removeLines = () => {
  const linesField = getFullLines(field);
  points = points + numSquares(linesField);
  removeTileFromField(linesField, field, {x:0, y:0});
};

Then we have a function checking whether one of the remaining random tiles would still fit the field. At the time of writing this, I would probably refactor this method into the field.js file. Also this implementation has a side effect, changing the somethingFits variable. We’ll come back to this soon.

const checkIfSomethingFits = () => {
  somethingFits = randomTiles
    .filter(tile => tile !== false)
    .map(tile => tileFitsIntoField(tile, field))
    .reduce(or, 0)
  ;
};

Now the core of main, the handler for our custom drop Event from the three DragField instances. Whenever we drop a tile field over the playing field, we do quite a bunch of tasks if it fits.

What a bunch of tasks, but still quite readable, don’t you think so?

const dropHandler = (e) => {
  const tile = randomTiles[e.detail.spot];
  const offset = {x: e.detail.x, y: e.detail.y};
  const fits = tileFitsIntoFieldWithOffset(tile, field, offset);
  if (fits) {
    points = points + numSquares(tile);
    if (points > record) {
      record = points;
      localStorage.setItem('block-puzzle-record', record);
    }
    addTileToField(tile, field, offset);			
    field = field;
    randomTiles[e.detail.spot] = false;
    removeLines();
    if (!randomTileLeft()) {
      getThreeNewRandomTilesFields();
    }
    checkIfSomethingFits();
  }
}

When {somethingFits} turns false, the game is over. The UI magically renders a notice.

  {#if somethingFits===false}
    <div class="game-over">
      game over
      <button type="button" on:click={restart}>Play again</button>
    </div>
  {/if}

But don’t be sad, you can just start again, just by a click. For that we do

const restart = () => {
  field = createSquareField(size, 0);
  points = 0;
  getThreeNewRandomTilesFields();
  checkIfSomethingFits();
};

Sources»

That’s it, I think we are done reading and maybe eplaining some of the code. I hope you enjoyed. You can either skip back and play the game or you can have a look at the sources. I also made an updated REPL if you like that more.