Click here to Skip to main content
Click here to Skip to main content

Double-Dartris

, 24 Apr 2014 CPOL
Rate this:
Please Sign up or sign in to vote.
Implementing a Tetris-like game for the web using Dart

Introduction

In this article I'll cover a way to use Dart in order to implement a HTML5 Tetris-like game. As I've covered the basics of web game development using Dart before, the main focus of this article will be implementing Tetris-like game logic and actions.

There's an early version compiled to JavaScript if you want to have a look at what it looks like in action.

Using the code

The zip-archive contains a Dart Editor project, just unzip and import.

Requirements

I defined a set of requirements for my Tetris implementation:

  • Some form of effects; I wanted something extra to happen when a row was cleared rather than just the blocks dissappearing.
  • Wall kick; A feature of some Tetris-like games is that if the player tries to rotate a piece while it's too close to the edge of the playing field, it will be "kicked" inwards towards the center of the playing field (provided there's room there).
  • Sound; The game should play sounds as different events take place.
  • Configurable keys; One of the hardest (I find) part of game development is to finish everything that's not game-play specific, such as intros and menus, so for this one I'm going to have to add at least a basic way of configuring the keys.
  • Twist; The game should not be a bulk-standard version of Tetris, but should have some twist to it.

For the Twist part I ended up doing the double-thingy, where the player controls two games at once, one upside-down and the other the right way up.

Fundamentals

In Tetris the players rotates and moves pieces all made up of four squares. The rule for how pieces look or are constructed is that each square must be adjecant to another square, and they must be sharing at least one full side with at least one neighbour.

There are seven distinct configurations that the squares can occupy, had there been three squares in stead of four it would have been three and for five squares a total of *something configurations exists.

Because of the simple rule it is possible to generate all possible pieces and while this would be the approach I would have taken if the pieces were made up of five or more squares it is not required when there are only seven possible pieces. It is quicker and tidier (I think) to manually define the seven pieces.

In Double-Dartris there are three classes that are fundamental;

  • Block; which represents a single square or part of a falling piece.
  • Piece; which represents a falling piece by being a set of Blocks.
  • PlayingField; which represents the area where the Pieces fall and where the Blocks stack up.

The Code

Block

The Block class represents a single square, either as part of a falling Piece or as a Block that has settled somewhere in the PlayingField. The reason I need both Blocks and Pieces is that the characteristics of a Piece changes drastically as soon as it's settled, and clearing a row can clear part of a Piece.

Because the Block is an abstraction of such a basic thing it essentially contains only two things;

  • Position
  • Color

In addition to holding the above data, it also knows how to render itself.

Notice that it does not have a concept of size, something that would be required for rendering. This is because the block has an implicit size of 1x1, and the render method knows how to translate and scale that into the correct onscreen position. It does this by projecting a game-area onto a screen:

void render(final CanvasRenderingContext2D context, final Rect screen, final Rect gameArea) {
  final int w = screen.width ~/ gameArea.width;   
  final int h = screen.height ~/ gameArea.height;
  final Rect block = new Rect(w * (gameArea.left + position.x), h * (gameArea.top + position.y ), w, h);
  fillBlock(context, block, _color);
}
    

In the above the screen is a Rect in pixels, and the gameArea is in field units, i.e. if the game is ten blocks wide and twenty blocks tall gameArea is 10x20.

Doing it this way decouples the game-play logic from the rendering logic, the Block can be rendered in a small area or fullscreen and it would still look correct as the width w and height h are calculated as the distances you'd get if gameArea was stretched onto screen.

The ~/ operator is a Dart convenience operator that gives the integer result of a division, it is a faster, shorter version of normal division plus an explicit int conversion:

// Do this
final int w = screen.width ~/ gameArea.width;
// Do not do this
final int w = (screen.width / gameArea.width).toInt();
    

The full listing for Block looks like this:

class Block {
  final Position position;
  final ColorPair _color;
  
  Block(this.position, this._color);

  String toString() => "B@$position";
  
  get hashCode => position.hashCode;

  operator ==(final Block other) {
    return position == other.position;
  }
  
  void render(final CanvasRenderingContext2D context, final Rect screen, final Rect gameArea) {
    final int w = screen.width ~/ gameArea.width;   
    final int h = screen.height ~/ gameArea.height;
    final Rect block = new Rect(w * (gameArea.left + position.x), h * (gameArea.top + position.y ), w, h);
    fillBlock(context, block, _color);
  }
}    
    

It might seem peculiar that two Blocks are considered the same if their positions are the same regardless of their color, but the position in the PlayingField is what makes a block unique (as will be apparent when I cover PlayingField later).

Piece

The Piece class represents the group of four Blocks that is currently falling across the PlayingField. It delegates the rendering to the render method on Block.

The shape of the piece is defined by it's final List<Block> _positions; member that hold the blocks making up the default configuration of the Piece. Using properties for position and rotation in the PlayingField the Piece transforms the default Blocks to the current representation on every rendered frame. This is obviously not very efficient but it shouldn't really matter for a game as simple as a Tetris.

List<Block%gt; _getTransformed() {
  final List<Block%gt; transformed = new List<Block%gt;();
  for(int i = 0; i < _positions.length; ++i) {
    Block block = _positions[i];
    for(int r = 0; r < _rotation; ++r) {
      block = new Block(new Position(-block.position.y, block.position.x), block._color);
    }
    transformed.add(new Block(_position + block.position, block._color));
  }
  
  return transformed;
}

void render(final CanvasRenderingContext2D context, final Rect screen, final Rect gameArea) {
  final List<Block%gt; transformed = _getTransformed();
  for(int i = 0; i < transformed.length; ++i) {
    final Block block = transformed[i];
    block.render(context, screen, gameArea);
  }
}
    

Another responsibility of the Piece class is to accept move and rotate requests from the controller. Instead of checking if a move is valid before the move (by valid I mean not moving outside the bounds of the PlayingField or into a Block), all moves are accepted and upon completion the validity of the current state of the PlayingField is checked and if found to be invalid it's rolled back to the previous state.

Using this try-rollback method the individual methods for moving (move horizontally, soft drop and rotate) become fairly simple and essentially only delegate a very small payload to a method called _tryAction.

  /// This method runs the transaction delegate, then verifies that the piece is still in a valid position
  /// if the field, if it is not valid the rollback delegate is run to undo the effect.
  bool _tryAction(void transaction(), void rollback(), final Set<Block%gt; field, final Rect gameArea) {
    transaction();
    final Iterable<Block%gt; transformed = _getTransformed();
    final int lastLine = falling ? gameArea.bottom : gameArea.top - 1;
    if (transformed.any((b) =%gt; b.position.y == lastLine || b.position.x < gameArea.left || b.position.x %gt;= gameArea.right || field.any((fp) =%gt; fp == b))) {
      rollback();
      return true;
    }
    else {
      return false;
    }
  }
    

Two delegates are used for the action and the rollback, first transaction is executed (and this mutates the position and/or the rotation) and after the state has been checked using the transformed version, transformed using the new parameters, it is either left that way or rolled back if found to be invalid. To rollback the rollback delegate is invoked.

Because of the try-rollback approach the code for moving a Piece becomes a single line with two delegates that are equal and opposite.

  bool move(final Position delta, final Set<Block> field, final Rect gameArea) {
    return _tryAction(() => _position += delta, () => _position -= delta, field, gameArea);
  }
    

Adjust the _position by a _delta amount, and if it doesn't yield a valid state then rollback by adjusting by the negative _delta. Rotating a piece would be similarly simple if it wasn't for the wall-kick feature that moves the piece inwards if the rotation is blocked by the walls. To wall-kick the try-rollback action is attempted as many times as there are Blocks in the Piece, that makes sure that all required positions of kick have been tried as there's never any need to kick more than that to find a clear space.
For each iteration the rotation is applied and the kick distance is increased from 0 to number of Blocks, if the rotation was valid it stays, otherwise it's rolled back and tried again with a higher value for the kick distance.

  /// Helper method for the rotate method
  void _wallKickRotate(final Position kickDirection, final bool rollback) {
    _rotation += rollback ? -1 : 1;
    if (rollback)
      _position -= kickDirection;
    else
      _position += kickDirection;
  }
  
  bool rotate(final Set<Block> field, final Rect gameArea) {
    final int originalX = _position.x;
    for(int i = 0; i < _positions.length; ++i) {
      final Position kickDirection = new Position(_position.x < gameArea.horizontalCenter ? i : -i, 0);
      if (!_tryAction(() => _wallKickRotate(kickDirection, false), () => _wallKickRotate(kickDirection, true), field, gameArea)) {
        if (_position.x != originalX)
          _audioManager.play(_position.y % 2 == 0 ? "wallKickA" : "wallKickB");
        return true;
      }
    }
    return false;
    
    // This is rotating without wall kicking    
    //return _tryAction(() => ++_rotation, () => --_rotation, field, gameArea);
  }
    

Again, this is not optimizing for performance at all and there are obviously much more efficient ways of doing this.

PlayingField

The PlayingField class represents the area the Pieces fall in and Blocks occupy. It accepts requests to move, rotate and drop the current Piece and uses a factory to generate the next Piece when the current one settles in the field into Blocks.

The field is also responsible for checking if any rows are fully occupied by Blocks and should be collapsed. The main purpose of the class is game-logic so it doesn't calculate the new score when a row is collapsed, it simply returns the Blocks that were collapsed. The reason it returns the blocks and not just a number of how many collapsed is that the special effects require the position of the collapsed Blocks and I don't want the PlayingField to know about the special effects, that's not game-logic.

Further, the PlayingField is in charge of detecting the game over state, it also knows how to render itself but that action is delegated to the render methods on Piece and Block.

Storing the Blocks

Instead of a two-dimensional array to represent the field I decided to try something a little bit different; the Blocks in the PlayingField are kept in a set.

    final Set<Block> _field = new HashSet<Block>();
    

This is why the equals overload on Block only consider position and not color.

I did it this way to try to use the LINQ-like functions on Iterable instead of accessing a grid-like structure. There isn't anything particular clever about that approach, I just wanted to see what it would look like implementing it that way.

Collapsing a row

To collapse a row the field will iterate through all rows from the top (or bottom for the flipped side) and if the number of Blocks on one row equals the width of the _gameArea (i.e the number of possible horizontal Blocks) then the row is cleared and anything above is moved down one step.

Since I don't care about the performance the Blocks above the cleared row aren't actually moved, they're deleted and readded, that's an effect of the Block class being immutable.

  Iterable<Block> checkCollapse() {
    final int rowDirection = _isFalling ? 1 : -1;
    final Position gridDelta = new Position(0, rowDirection);
    final int firstRow = _isFalling ? _gameArea.top : _gameArea.bottom;
    final int lastRow =  _isFalling ? _gameArea.bottom : _gameArea.top - 1;
    
    final Set<Block> collapsed = new HashSet<Block>();
    for(int r = firstRow; r != lastRow; r += rowDirection) {
      final Iterable<Block> row = _field.where((block) => block.position.y == r).toList();
      if (row.length == _gameArea.width) {
        collapsed.addAll(row);
        _field.removeAll(row);
        final Iterable<Block> blocksToDrop = _field.where((block) => _isFalling ? block.position.y < r : block.position.y > r).toList();
        _field.removeAll(blocksToDrop);
        _field.addAll(blocksToDrop.map((block) => new Block(block.position + gridDelta, block._color)));
      }
    }
    return collapsed;
  }
    

The process of checking for the game over state is as simple as checking if any Blocks are settled on the first row of the playing field.

FieldController

The class that is the glue between the game state (StateGame) and the game logic (PlayingField) is the FieldController.

The FieldController is responsible for reading and relaying user input to the PlayingField as well as creating and maintaining the graphical effects that are not directly related to game logic (in Double-Dartris those effects are messages that animate when multiple rows have been cleared in one drop).

Having a controller provides a neat de-coupling between the abstraction of the game and the interface with the user, and it allows for things like adding an AI-player. Since the PlayingField class cares about what can be done in the game (move left, move right, drop, rotate, clear row or lose the game) it shouldn't have any intimate knowledge about what initiates those things, that's the controller's job. Even the dropping of a Piece is the job of the controller, the field only knows how to drop and whether or not the Piece has settled into Blocks.

As the game "ticks" whenever the browser provides an animation frame the controller needs to keep track of elapsed time and only request a drop when enough time (as dictated by the current level) has passed.

There are various helper methods in FieldController but the relevant method is control which looks like this:

  Iterable<Block> control(final double elapsed, final Rect screen) {
    if (isGameOver) 
      return new List<Block>();  
    _accumulatedElapsed += elapsed;

    _checkRotating();
    _checkMoving();
    
    // This way it's getting very difficult, very fast
    _dropInterval = (1.0 / level);
    double dropTime = _dropInterval * (isDropping ? 0.25 : 1.0);
    dropTime = min(dropTime, isDropping ? 0.05 : dropTime);
    if (_accumulatedElapsed > dropTime) {
      _accumulatedElapsed = 0.0;
      score += level;
      if (_field.dropCurrent()) {
        // Piece has settled, check collapsed and then generate a new Piece
        final Iterable<Block> collapsed = _field.checkCollapse();
        score += collapsed.length * collapsed.length * level;
        _field.next(); 
        final numberOfRowsCleared = collapsed.length ~/ _field._gameArea.width;
        switch(numberOfRowsCleared) {
          case 0: _audioManager.play("drop"); break; // 0 means no rows cleared by the piece came to rest
          case 2: _audioManager.play("double"); break;  
          case 3: _audioManager.play("triple"); break;  
          case 4: _audioManager.play("quadruple"); break;  
        }
        
        final TextEffect effect = _buildTextEffect(numberOfRowsCleared, screen); 
        if (effect != null)
          _textEffects.add(effect);
        return collapsed;
      }
    }
    return new List<Block>();
  }
    

Essentially what the method does is:

      Process Horizontal Move
      Process Rotate
      If it is time for Piece to Drop Then
        Drop Piece
        If dropped piece settled then
          Collapse rows // If any
          Play some sounds
        End If
        
        If applicable Then Create Text Effect
        
        Generate Next Piece
      End if
    

The controller is constructed with (amongst other things) the keys that will control the Piece, that makes it easy to have the keys used in the game configurable which was one of the requirements I set out to cover.

The FieldController (or actually a pair of them) are used by the main state StateGame.

StateGame

The game uses a state machine much like the one I described in my previous article on Dart game development, and while the StateGame is the most interesting state, the full state machine looks like this:

The main state wraps up two FieldControllers (one falling and one rising game) and controls the animation that takes place when a row is cleared or the game is over.

It is also where the game interacts with the HTML elements used to display most of the game's text. I wanted that portion out of both the PlayingField and the FieldController as writing unit tests becomes harder when the code couples to the DOM-tree. Granted, I have wrapped up the calls to set and clear the text in a way that could be extended to allow mocking but I thought it overkill to go all the way for something as simple as a Tetris game.

Text Effects

When two or more rows are cleared in one drop a text message saying "Double", "Triple" or "Quadruple" fades in and out quickly whilst also being animated. These effects are owned by the PlayingField and there are two classes that make up the text effect implementation.

Text

The Text class represents the text in a single state, and is able to render the text in that state. By state I mean properties such as:

  • Font size
  • Position
  • Rotation
  • Alpha blend

In addition to the above the string that is the text and the color is also available on the Text class, but they're not allowed to be animated.

As the transform can be set (position and rotation together make up the transform) for the Text and since the transform is set globally, i. e. for all subsequent draws on a CanvasRenderingContext2D, the Text's render method saves the current render transform before applying the properties of the text. Then, when it has rendered it's own current state it restores the previous transform.

The render method takes the properties of the Text and applies them to the CanvasRenderingContext2D, so _position becomes the translation, _fontSize and _font become the font, etc.

  void render(final CanvasRenderingContext2D context, final Rect screen) {
    context.save();

    context.translate(_x, _y);
    context.rotate(_angle);
    context.globalAlpha = _alpha;
    context.fillStyle = _color.primary.toString();
    context.shadowColor = _color.secondary.toString();
    context.shadowOffsetX = 2;
    context.shadowOffsetY = 2;
    context.font = "${_fontSize}px ${_font}";
    context.textAlign = _align;
    context.textBaseline = "middle";
    context.fillText(_text, 0, 0);

    context.restore();
  }
    

So while the Text class holds and renders a single "state", there's another class that mutates that state, the TextEffect class.

TextEffect

The TextEffect class has two very simple responsibilities;

  • Keep track of how long the effect lasts for, the duration.
  • Mutate the state of it's Text using animators.

The duration (in seconds) is passed as a double to the TextEffect constructor and on every update the elapsed time is added to a cummulative _elapsed field. When _elapsed is greater than _duration the effect is complete and can be removed from the collection owned by the FieldController.

For each frame the fraction between elapsed and duration is calculated, and it is this fraction that is fed as input to the animators that in turn yield new values for the properties of the Text.

The animators are simple function pointers

typedef double DoubleAnimator(final double _elapsed);
    
class TextEffect {
  Text _text;
  
  double _elapsed = 0.0;
  double _duration;
  
  DoubleAnimator _sizeAnimator;
  DoubleAnimator _xAnimator;
  DoubleAnimator _yAnimator;
  DoubleAnimator _angleAnimator;
  DoubleAnimator _alphaAnimator;

  ...
}     
    

This way the update method of TextEffect will update, for example, the X part of the Text's position like this:

void update(final double elapsed) {
  _elapsed += elapsed;
  final double fraction = _elapsed / _duration;
  _text._x = _xAnimator(fraction).toInt();
    
  ...
}  
    

As the value passed in to the animator is the fraction between _elapsed and _duration the animators are not supposed to answer the question "what is the state after N seconds?" but rather answer "what is the state after P% of the duration has passed?".

Doing it this way makes it easy to manipulate the properties of the text.

As an example; the text effect played when the player clears four rows in one drop looks like this:

final TextEffect effect = new TextEffect(new Text("QUADRUPLE!", "pressstart", "center", color), 1.0);
effect._sizeAnimator = (f) => 10.0 + f * 30;
effect._xAnimator = (f) => screen.horizontalCenter;
effect._yAnimator = (f) => screen.verticalCenter;
effect._angleAnimator = (f) => sin(f * 2 * PI);
effect._alphaAnimator = (f) => 1.0 - f;
    

This sets up a text that grows from 10.0 to 30.0 points and wobbles a bit whilst fading out in a linear fashion.

Points of Interest

Summary

I think I managed to complete the requirements I set out to implement but as this is only my second attempt at a Dart program/game the code got messier than I wanted. I don't think it's aweful but as I go I discover ways of doing things that suits Dart better. What this second project has reconfirmed for me though is that because of the similarities between Dart and languages I normally use on a daily basis (Java, C#) I am alot more efficient when producing stuff for the web than I would be in JavaScript. But still, as I discussed in my previous Dart article, I find the lack of resources and tooling for Dart lacking.

Hindsight is always 20-20

As in Double-Dartris one game falls and the other rises my Piece, PlayingField and FieldController all care about what is up and down and what direction a Piece falls in. What I should have done was to not do that as it convolutes the code for very little upside. The smarter thing would have been to take care of the flipping of one game exclusively in the rendering and kept the game logic the same for both. That would have essentially cut down unit-testing effort by half.

History

  • 2014-04-24; First version.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Fredrik Bornander
Software Developer (Senior)
Sweden Sweden
Article videos
Oakmead Apps Android Games
 
21 Feb 2014: Best VB.NET Article of January 2014 - Second Prize
18 Oct 2013: Best VB.NET article of September 2013
23 Jun 2012: Best C++ article of May 2012
20 Apr 2012: Best VB.NET article of March 2012
22 Feb 2010: Best overall article of January 2010
22 Feb 2010: Best C# article of January 2010
Follow on   Google+   LinkedIn

Comments and Discussions

 
QuestionNice one swede PinmvpSacha Barber25-Apr-14 4:01 
AnswerRe: Nice one swede PinprofessionalFredrik Bornander25-Apr-14 4:18 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web03 | 2.8.1411019.1 | Last Updated 25 Apr 2014
Article Copyright 2014 by Fredrik Bornander
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid