Functional Thread 13 : Tetris Game

[)roi(]

Executive Member
Joined
Apr 15, 2005
Messages
6,282
Reaction score
405
Location

Tetris
757796

This thread will be a code heavy thread targeted at an intermediate level.

It will focus on building a Tetris game using a pure functional approach. During the course of the build we'll build a pure functional logic core mixed initially with an imperatively all in one coded MVC (Model / View / Controller) to show that functional code can easily exist side by side with both imperative and OOP style of design.

In the second part of this thread we'll push the boundaries of the functional design by converting all the imperative code in our MVC (Massive View Controller) in order to create a clear separation between the Model, the View and the Controller, at the same time splitting off as much of the code as possible to a multi-platform shared codebase; with the final intention of creating a project that is capable of running on Windows, Linux, macOS and Android... and that can fairly easily be adapted for iOS, Xbox, Playstation, etc.

To achieve the clear separation with MVC; we'll be building a custom Actor Model implementation of the Redux state engine that will process all state (model) changes on a background thread.


What will be needed for anyone that would like to follow along:
  1. Language : F# (latest version 4.7)
  2. .Net framework: 3.0
  3. Game framework : Monogame.Net
  4. IDE: Visual Studio for Windows or Visual Studio for Mac, or alternatively Visual Studio Code on Linux
Although I've chosen to use F# for this build; the code should quite easily translate to any of the mainstream languages. F# has built in support for the Actor Model (called MailboxProcessor); this in not available on C# or VB.Net, however as a substitute you can use Akka.Net for C# and Vb.Net and similarly Akka for Java, Scala and Kotlin.

Alternatively you can use any existing Redux or Flux implementation as a substitute. In non .Net languages you'll have to also pick a game framework, or use libGDX or OpenTK or .... and build your own game loop. As for Javascript; I'd suggest just using Redux or Flux with the canvas for the render.

Any questions you may have should either be posted into this thread or sent as a PM.

Timeline
This is probably going to be a Christmas into New Year build, but as I've already written quite a bit of the code already as part of a FP teaching exercise. I should be able to start dropping code in a day or two; the explanation of the design approach and implementation is the part that typically takes up more time. Either way we should wrap this up in the new year or before; Finally I'll end off by pushing a final working project up to github.
 
Last edited:
Tetris design specifications
The design of the game will follow roughly along with this set of Tetris guidelines and will use the scoring algorithm for Tetris on the Nintendo. The goal will be to focus more on building a suitable equivalent; rather than trying for an exact match of the internal timing of an official release.

I won't be reposting information that is available for reading on https://tetris.fandom.com/wiki/Tetris_Wiki -- instead I'll start by focusing on breaking down the design into a group of functions that on the whole should cover all the game logic.

Once the logic is built, I'll then imperatively build out the rendering of game using Monogame's framework -- as I said previously the goal of the first run of the MVC build will be to quickly flesh out a working game, rather than worry about the maintainability, structuring / design or ease and extendibility of the design.

After we have a working game; the goal will then be to refactor and re-architect the code to create a clear separation between the Model, View and Controller; and to make it far easier to maintain and extend for example:
  • adding on a loading screen
  • adding a menu
  • adding a high scores screen
  • etc...
We'll achieve this by building a functional state engine similar in design to Redux, except the F# design will be built around F#'s builtin Actor Model (MailboxProcessor) in order to achieve complete separation of the state (model) and view management from the controller by fielding off these computations onto a separate background thread.

Game Assets
Each of the Tetrominos are made up of a set of 4 blocks whose edges are connected. There are a total of 7 different tetrominos:
757726
Starting from the left top corner of the Tetrominos; we'll name each of Tetrominos and assign a character to uniquely identify the blocks of each Tetrominos from another:
  1. Stick -- character: 'I' -- Monogame Colour: DeepSkyBlue
  2. Left periscope -- character: 'J' -- Monogame Colour: Blue
  3. Right periscope -- character: 'L' -- Monogame Colour: DarkOrange
  4. Square -- character: 'O' -- Monogame Colour: Yellow
  5. Right dog -- character: 'S' -- Monogame Colour: LimeGreen
  6. Tee -- character: 'T' -- Monogame Colour: Magenta
  7. Left dog -- character: 'Z' -- Monogame Colour: Red
Tetromino block image (colour will be added with monogame draw code):
757792

Empty board block image:
757720

Loading screen:
757722

Font (to simulate older pixelated text):
https://www.wfonts.com/font/space-invaders

757732

Sounds (pending):
I'll share links once I've found some free assets.
 
Last edited:
Project setup
In Visual Studio we create a .Net Core Console Application project using the F# language and .Net core framework 3.0 or later; this standard console project template will then be adjusted to launch the Monogame framework.

Nugets to add for Monogame
  • Monogame.Content.Builder (3.7.0.9)
  • Monogame.Framework.DesktopGL.Core (3.7.0.7)

Add a new F# source file to the project with the name Controller.fs

Add the following code to the Controller.fs -- this is a basic template for the Monogame intialisation and game loop.
C#:
//
// Controller.fs
//
namespace Endofunk
open System
open Microsoft.Xna.Framework
open Microsoft.Xna.Framework.Graphics
open Microsoft.Xna.Framework.Input

type Controller () as this =
  inherit Game()

  let graphics = new GraphicsDeviceManager(this)
  let mutable spriteBatch = Unchecked.defaultof<SpriteBatch> 

  do
    this.Content.RootDirectory <- "Content"
    this.IsMouseVisible <- true
    graphics.PreferredBackBufferWidth <- 480
    graphics.PreferredBackBufferHeight <- 640

  override this.Initialize() =
    base.Initialize()

  override this.LoadContent() =
    spriteBatch <- new SpriteBatch(this.GraphicsDevice)

    // TODO: use this.LoadContent to load your game content here

  override this.Update (gameTime) =
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back = ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) then this.Exit()

    // TODO: use this.UpdateContent to load your game content here

    base.Update(gameTime)

  override this.Draw (gameTime) =
    this.GraphicsDevice.Clear Color.Black

    spriteBatch.Begin()
    // TODO: Add your drawing code here

    spriteBatch.End()
    base.Draw(gameTime)

Add a Content folder for the Assets
Add a new folder to the project with the name Content. Add a new text file inside the Content folder with the name Content.mgcb. Add the following text to Content.mgcb:
Code:
#----------------------------- Global Properties ----------------------------#

/outputDir:bin/$(Platform)
/intermediateDir:obj/$(Platform)
/platform:DesktopGL
/config:
/profile:Reach
/compress:False

#-------------------------------- References --------------------------------#


#---------------------------------- Content ---------------------------------#
Next we need to change the properties of the Content.mgcb to enable the Build Action for Monogame content builder pipeline, which will compile our game assets.
Change the Build Action property field to MonoGameContentReference.

Change Program.fs to start up the Monogame game loop
C#:
open Endofunk

[<EntryPoint>]
let main argv =
  use controller = new Controller()
  controller.Run()
  0

At this point the project can be compiled and run and should display a portrait window with a black background. Pressing the esc key will exit the application.
 
Last edited:
Gameboard and Tetromino Data Types
Both the gameboard and the 7 tetrominos can be easily represented in collection type like List; considering that the width / height varies; a jagged collection type will be used for both.


Game board setup
From the specification we can see that different characters are used to denote the blocks of the various tetrominos; therefore the gameboard will be a jagged collection of characters; where an unfilled cell is the space character.

Add the following code to the Controller.fs after the let graphics... line to define the board size, and initialise a jagged list with the outer list of length Y and the inner list of length X.
C#:
...
let mutable spriteBatch = Unchecked.defaultof<SpriteBatch>
let size = new Point(10, 20)
let mutable board = [ for _ in 1..size.Y do yield [ for _ in 1..size.X do yield ' ' ] ]
...


Definition of Tetrominos
Add a new F# source file to the project with the name Tetromino.fs

Similar to the board the Tetrominos are also jagged 2D structures with the space character for an unfilled cell, and the relevant character from the previous post for a filled cell. Add the code below to the Tetromino.fs file.
C#:
namespace TetrisShared
open System
open Microsoft.Xna.Framework

[<AutoOpen>]
module public Tetromino =
  let public leftPeriscope =
    [ [ ' '; 'J' ]
      [ ' '; 'J' ]
      [ 'J'; 'J' ] ]

  let public rightPeriscope =
    [ [ 'L'; ' ' ]
      [ 'L'; ' ' ]
      [ 'L'; 'L' ] ]

  let public stick =
    [ [ 'I' ]
      [ 'I' ]
      [ 'I' ]
      [ 'I' ] ]

  let public leftDog =
    [ [ 'Z'; 'Z'; ' ' ]
      [ ' '; 'Z'; 'Z' ] ]

  let public rightDog =
    [ [ ' '; 'S'; 'S' ]
      [ 'S'; 'S'; ' ' ] ]

  let public square =
    [ [ 'O'; 'O' ]
      [ 'O'; 'O' ] ]

  let public tee =
    [ [ ' '; 'T'; ' ' ]
      [ 'T'; 'T'; 'T' ] ]


Infinite Sequence of Tetrominos
We require a function that can generate an infinite sequence of randomly generated tetrominos, from which we'll take 1 for the current falling tetromino and another for the next tetromino. The benefit of an infinite sequence is that we can keep taking tetrominos and the sequence will never run out.

Add the following code to the Tetromino.fs file below the tee tetromino:
C#:
let public Tetrominos =
    let r = Random()
    let parts = [ leftPeriscope; rightPeriscope; stick; leftDog; rightDog; square; tee ]
    Seq.initInfinite(fun _ -> parts.[r.Next(0, List.length parts)])
Note:
The Seq type is a type with a 1-to-1 correspondence with C#'s IEnumerable type. I.e. A type that supports C# Linq, or stated in functional terms; IEnumerable is a monadic data type.

Convert Tetromino characters to Colours
To correct colour the block texture according to the type of tetromino, we need a function that will take a tetromino character and return a Monogame colour. Add the following code to the Tetromino.fs file below the infinite sequence.

C#:
let public char2Color c =
    match c with
    | 'J' -> Color.Blue
    | 'L' -> Color.DarkOrange
    | 'I' -> Color.DeepSkyBlue
    | 'Z' -> Color.Red
    | 'S' -> Color.LimeGreen
    | 'O' -> Color.Yellow
    | 'T' -> Color.Magenta
    | ' ' -> Color.Transparent
    | _ -> Color.Beige

Note:
If you were wondering about how F# determines the type of the function; it does this by examining the usage of the input parameter(s) and the return value -- in most cases the F# compiler is able to figure out the type without any type adornments. It's not perfect as you will see in the next post; because there are times where the compiler needs help to figure out the function type signature.

Reveal the type signatures:
F# has a feature that allow you to see what types the compiler has assigned; you do this by either mousing over the function name and/or the parameters and F# will reveal its computed type signature. Alternatively Visual Studio has a setting to permanently Show function type signatures above the functions.

Next post
The next post will focus on the functions needed in the game's logic engine, for example:
  • Rotating the tetrominos
  • Merging a tetrominos with the board
  • Detect collisions
  • Compute the Score
  • Removed filled lines
  • etc...
 
Last edited:
Breaking Down: Logic Engine Components
We can break down the logical aspects of the game into roughly the following set of functions.
  1. Rotation of a tetromino
  2. Merging of a tetromino with the board
  3. Computing the vertical collider points for a tetromino
  4. Computing the horizontal collider points for a tetromino
  5. Checking for vertical collisions
  6. Checking for horizontal collisions
  7. Removing full lines from the board
  8. Adding empty lines to augment the board by the number of lines removed
  9. Compute the score
  10. Checking for game over
 
Create a new F# source called Tetris.fs file to contain all the game logic engine functions.
Add the following code to this file:
C#:
//
// Tetris.fs
//
namespace TetrisShared
open Microsoft.Xna.Framework

[<AutoOpen>]
module public Tetris =


Rotating a Tetromino (Left 90°)
One of the key motion aspects of Tetris is the ability to rotate a tetromino to fit a particular target drop point on the board. The default rotation direction is left 90° which can be achieved with the approach shown in the diagram below:

757982

To read down the columns; we need a xRange of the column indexes; the 1st List.map iterates over the column indexesl; storing the index value as variable n.
The 2nd List.map iterates over the rows; xs represents a row and we retrieve a specific indexed column with the n variable.

Helper functions:
xLen returns the last index position for the columns
xRange return a range from 0 to xLen

Add this code to the Tetris.fs file, just below the module public Tetris = line, indented in 1 tab position. F# like python uses indentation to group code in the same way that C based languages group code with curly braces.
C#:
  let xLen (xss : 'a list list) = List.length xss.[0] - 1
  let xRange xss = [ 0..(xLen xss) ]

  let public rotateLeft xss =
      xRange xss
      |> List.map (fun n -> xss |> List.map (fun xs -> xs.[n]))
      |> List.rev


Rotating a Tetromino (Right 90°)
Although we only need the rotateLeft function for the game; we are going to include a function to rotate right 90° for a variation on tetris and / or enhancement that allows both left and right rotations.

The workflow to rotate right is similar to rotate left, except that we perform a reverse before we read a indexed column; the diagram below illustrates this:
758002
Add this code to the Tetris.fs file, just below the rotateLeft function at the same indented position.
C#:
  let public rotateRight xss =
    xRange xss
    |> List.map (fun n -> xss |> List.rev |> List.map (fun xs -> xs.[n]))



For clarity
The Tetris.fs file should look like this:
C#:
//
// Tetris.fs
//
namespace TetrisShared
open Microsoft.Xna.Framework

[<AutoOpen>]
module public Tetris =
  let xLen (xss : 'a list list) = List.length xss.[0] - 1
  let xRange xss = [ 0..(xLen xss) ]

  let public rotateLeft xss =
      xRange xss
      |> List.map (fun n -> xss |> List.map (fun xs -> xs.[n]))
      |> List.rev

  let public rotateRight xss  =
    xRange xss
    |> List.map (fun n -> xss |> List.rev |> List.map (fun xs -> xs.[n]))
 
Last edited:
Merging a Tetromino with the Board
The merging of a tetromino with the board; both jagged lists; requires a index offset to represent where exactly the tetromino should be merged on the board.

The operation of merging is a fairly easy operation; we essentially have 2 loops; one for the rows and another for the columns; once rows / columns matches the placement offset of the tetromino we simply replace values on the board for the matching tetromino values. We'll use the Point type from the Monogame framework to represent the placement offset.

The code example below is written as a set of recursive functions; iterY that iterates over the rows, and iterX that iterates over the columns -- the value replace is part of the iterX recursive function. F# has builtin support for tail recursion so its fairly easy to write recursion functions without having to be too concerned about stack overflows.

Add the following code below the rotateRight functions in the Tetris.fs file:
C#:
  let public merge (tetromino : 'a list list) (p : Point) f (board : 'a list list) =
    let rec iterX xs r c =
      match xs with
      | [] -> []
      | ch :: cf -> (if c >= p.X && c <= (p.X + (xLen tetromino)) && (f tetromino.[r - p.Y].[c - p.X]) then tetromino.[r - p.Y].[c - p.X] else ch) :: iterX cf r (c + 1)
    let rec iterY xss r =
      match xss with
      | [] -> []
      | rh :: rf -> (if r >= p.Y && r <= (p.Y + (yLen tetromino)) then iterX rh r 0 else rh) :: iterY rf (r + 1)
    iterY board 0

The weird looking syntax in the match ... with statement ch :: cf -> ... is a special type of pattern matching operation that splits the head element (ch) from the rest of the tail elements (cf) -- this make it easy to recurse over a collection type without having to worry about accessing anything out of bounds; we simply keep recursing over the tail until its empty.
The r and c variables that get passed into the recursive functions are used to track the offset to match this against the x, y insertion point.
The exit cases for the recursive functions are the | [] -> [] pattern match lines; essentially once the tail of the list is exhausted, the recursion terminates.

Note:
The :: syntax after the match case is a special insertion operator called Cons that essentially prepends an element onto an existing collection; and the reason we prepend an element as opposed to append, is because of the constant time and cost for a prepend operation.

Predicate Function
The f parameter is a predicate function e.g. (char -> bool) that is used to determine what values on the board can be replaced by values from the tetromino. for example:
C#:
fun x -> x <> ' '
A predicate function that checks if the input parameter is not equal to a space character.


Next Post
At this point we have enough code to do some early testing; so in the next post we go about setting up some game assets -- so that we can merge a tetromino or two on the board and then render the board in the game loop.
 
Last edited:
Adding and Configuring Assets
Copy the Block.png, Empty.png, and Logo.png files to Content folder in the project.

Edit the Content.mgcb file and ensure that it has the same text as below:
Code:
#----------------------------- Global Properties ----------------------------#

/outputDir:bin/$(Platform)
/intermediateDir:obj/$(Platform)
/platform:DesktopGL
/config:
/profile:Reach
/compress:False

#-------------------------------- References --------------------------------#


#---------------------------------- Content ---------------------------------#

#begin Block.png
/importer:TextureImporter
/processor:TextureProcessor
/processorParam:ColorKeyColor=255,0,255,255
/processorParam:ColorKeyEnabled=True
/processorParam:GenerateMipmaps=False
/processorParam:PremultiplyAlpha=True
/processorParam:ResizeToPowerOfTwo=False
/processorParam:MakeSquare=False
/processorParam:TextureFormat=Color
/build:Block.png

#begin Empty.png
/importer:TextureImporter
/processor:TextureProcessor
/processorParam:ColorKeyColor=255,0,255,255
/processorParam:ColorKeyEnabled=True
/processorParam:GenerateMipmaps=False
/processorParam:PremultiplyAlpha=True
/processorParam:ResizeToPowerOfTwo=False
/processorParam:MakeSquare=False
/processorParam:TextureFormat=Color
/build:Empty.png

#begin Logo.png
/importer:TextureImporter
/processor:TextureProcessor
/processorParam:ColorKeyColor=255,0,255,255
/processorParam:ColorKeyEnabled=True
/processorParam:GenerateMipmaps=False
/processorParam:PremultiplyAlpha=True
/processorParam:ResizeToPowerOfTwo=False
/processorParam:MakeSquare=False
/processorParam:TextureFormat=Color
/build:Logo.png


Add Mutable Asset Variables in the Controller
Add the following mutable variables to the Controller.fs directly under the following line let mutable spriteBatch = Unchecked.defaultof<SpriteBatch>:

C#:
  let mutable block = Unchecked.defaultof<Texture2D>
  let mutable empty = Unchecked.defaultof<Texture2D>
  let mutable logo = Unchecked.defaultof<Texture2D>


Load Content into the Asset Variables
Add the following code to the to the LoadContent method below the following line spriteBatch <- new SpriteBatch(this.GraphicsDevice):
C#:
    // TODO: use this.LoadContent to load your game content here
    block <- this.Content.Load<Texture2D>("Block")
    empty <- this.Content.Load<Texture2D>("Empty")
    logo <- this.Content.Load<Texture2D>("Logo")

Add the following lines at the top of the Controller.fs below the following line open Microsoft.Xna.Framework :
C#:
open TetrisShared.Tetromino
open TetrisShared.Tetris


Code to Render the Board
Add the following code to the Draw method below the following line spriteBatch.Begin() and before the following line spriteBatch.End().
C#:
      for y in 0..(size.Y - 1) do
        for x in 0..(size.X - 1) do
          let c = board.[y].[x]
          let p = new Point(x * block.Width + 10, y * block.Height + 20)
          if c <> ' ' then
            spriteBatch.Draw(block, new Rectangle(p.X, p.Y, block.Width, block.Height), (char2Color c) * 1.0f)
          else spriteBatch.Draw(empty, new Rectangle(p.X, p.Y, empty.Width, empty.Height), Color.DarkSlateGray * 0.2f)


Finally let's merge some Tetrominos on the board
Add the following code to the Update method above the following line base.Update(gameTime) in order to merge a tetromino with the board:
C#:
board <- merge leftPeriscope (new Point(2, 2)) (fun a -> a <> ' ') board
board <- merge (rotateLeft rightPeriscope)  (new Point(2, 10)) (fun a -> a <> ' ') board


Controller Summary
The complete Controller.fs should look like this:
C#:
 //
// Controller.fs
//
namespace Endofunk
open System
open Microsoft.Xna.Framework
open Microsoft.Xna.Framework.Graphics
open Microsoft.Xna.Framework.Input
open TetrisShared.Tetromino
open TetrisShared.Tetris

type Controller () as this =
  inherit Game()

  let graphics = new GraphicsDeviceManager(this)
  let mutable spriteBatch = Unchecked.defaultof<SpriteBatch>
  let mutable block = Unchecked.defaultof<Texture2D>
  let mutable empty = Unchecked.defaultof<Texture2D>
  let mutable logo = Unchecked.defaultof<Texture2D>
  let size = new Point(10, 20)
  let mutable board = [ for _ in 1..size.Y do yield [ for _ in 1..size.X do yield ' ' ] ]
 
  do
    this.Content.RootDirectory <- "Content"
    this.IsMouseVisible <- true
    graphics.PreferredBackBufferWidth <- 480
    graphics.PreferredBackBufferHeight <- 640
  override this.Initialize() =
    base.Initialize()
 
  override this.LoadContent() =
    spriteBatch <- new SpriteBatch(this.GraphicsDevice)

    // TODO: use this.LoadContent to load your game content here
    block <- this.Content.Load<Texture2D>("Block")
    empty <- this.Content.Load<Texture2D>("Empty")
    logo <- this.Content.Load<Texture2D>("Logo")
 
  override this.Update (gameTime) =
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back = ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) then this.Exit()

    // TODO: use this.UpdateContent to load your game content here
    board <- merge leftPeriscope (new Point(2, 2)) (fun a -> a <> ' ') board
    board <- merge (rotateLeft rightPeriscope)  (new Point(2, 10)) (fun a -> a <> ' ') board
    base.Update(gameTime)
   
  override this.Draw (gameTime) =
    this.GraphicsDevice.Clear Color.Black
 
    spriteBatch.Begin()
    // TODO: Add your drawing code here
    for y in 0..(size.Y - 1) do
      for x in 0..(size.X - 1) do
        let c = board.[y].[x]
        let p = new Point(x * block.Width + 10, y * block.Height + 20)
        if c <> ' ' then
          spriteBatch.Draw(block, new Rectangle(p.X, p.Y, block.Width, block.Height), (char2Color c) * 1.0f)
        else spriteBatch.Draw(empty, new Rectangle(p.X, p.Y, empty.Width, empty.Height), Color.DarkSlateGray * 0.2f)
    spriteBatch.End()
    base.Draw(gameTime)

...and that's it for now... build and run the project and you should seen a window with a board and two tetrominos.

758232
 
Last edited:
Computing the Colliders
Computing the colliders for a tetromino uses the same process for both vertical and horizontal except for the a predicate that determines if a tetromino cell is either a vertical or horizontal collider.

To computer the collider we essentially iterate over every cell in the tetromino's jagged list and check if the predicate is true; and then create an offset as a Monogame Point.

Add the following code below the merge function in Tetris.fs
C#:
 let private getColliders xss f =
    yRange xss
    |> List.collect (fun y ->
    xRange xss
    |> List.collect (fun x -> f xss (new Point(x, y))))

Note:
List.collect is another name for a flatmap / bind operation; computes and then flattens the structure.

In the above getColliders function we use a new helper function called yRange; which like xRange creates a range of the row indexes.

Add the following code to Tetris.fs just after the line module public Tetris
C#:
module public Tetris =
  let xLen (xss : 'a list list) = List.length xss.[0] - 1
  let yLen (xss : 'a list list) = List.length xss - 1
  let xRange xss = [ 0..(xLen xss) ]
  let yRange xss = [ 0..(yLen xss) ]


Computing Vertical Colliders for a Tetromino
To determine if a cell in the tetromino is a vertical collider, we need to find the bottom edges; because tetrominos only collide downwards. The following rules determine if a cell is a collider:
  • Current cell is not a space character, and our row index is the last index
  • Current cell is not a space character and the cell below our index is a space character.
Add the following predicate code to Tetris.fs; below the getColliders function:
C#:
let private isVerticalCollider (xss : char list list) (p : Point) =
  if xss.[p.Y].[p.X] <> ' ' && (p.Y = (yLen xss) || xss.[p.Y + 1].[p.X] = ' ')
  then [p] else []


Computing Horizontal Colliders for a Tetromino
To determine if a cell in the tetromino is a horizontal collider, we need to find the horizontal edges (left and right). The following rules determine if a cell is a collider:
  • Current cell is not a space character, and our column index is the first index
  • Current cell is not a space character, and our column index is the last index
Add the following predicate code to Tetris.fs; below the isVerticalCollider function:
C#:
  let private isHorizontalCollider (xss : char list list) (p : Point) =
    if xss.[p.Y].[p.X] <> ' ' && (p.X = (xLen xss) || p.X = 0 || xss.[p.Y].[p.X + 1] = ' ' || xss.[p.Y].[p.X - 1] = ' ')
    then [p] else []


Helper functions to simplify the retrieval of vertical and horizontal colliders
Add the following code to Tetris.fs; below the isHorizontalCollider function:
C#:
  let private getVerticalColliders tetromino = getColliders tetromino isVerticalCollider
  let private getHorizontalColliders tetromino = getColliders tetromino isHorizontalCollider
 
Last edited:
Checking for collisions
Computing collisions for a tetromino takes its input from the colliders computed in the previous post. Collisions can occur below the vertical colliders, and left and right of the horizontal colliders; the same process is used for both vertical and horizontal collisions except for a predicate parameter.

Add the following code to Tetris.fs; below the getHorizontalColliders function:
C#:
  let private willCollide (board : char list list) (tetromino : char list list) (p : Point) f g =
    tetromino |> f |> List.map (fun a -> a + p) |> List.filter (g board) |> List.length > 0
We filter out all the colliders points that return true for the collision predicate, and if the result set length is greater than zero, then we have collided with an edge.



Predicate for vertical collision
Add the following code to Tetris.fs; below the willCollide function:
C#:
  let private checkVerticalCollision (xss : char list list) (p : Point) =
    p.Y = (yLen xss) || xss.[p.Y + 1].[p.X] <> ' '
We have a collision if the space below the collider is not the space character, or the Y position is equal to the board's max Y index.



Predicate for left collision
Add the following code to Tetris.fs; below the willCollide function:
C#:
  let private checkLeftCollision (board : char list list) (p : Point) =
    p.X = 0 || board.[p.Y].[p.X - 1] <> ' '
We have a collision if the space to the left of collider is not the space character, or the left collider X position is 0.


Predicate for right collision
Add the following code to Tetris.fs; below the checkLeftCollision function:
C#:
  let private checkRightCollision (board : char list list) (p : Point) =
    p.X = xLen board || board.[p.Y].[p.X + 1] <> ' '
We have a collision if the space to the right of collider is not the space character, or the right collider X position is equal to the board's max X index.

Helper functions to simplify the detection of vertical, left, and right collisions
Add the following code to Tetris.fs; below the checkRightCollision function:
C#:
  let public willVerticalCollide board tetromino position = willCollide board tetromino position getVerticalColliders checkVerticalCollision
  let public willLeftCollide board tetromino position = willCollide board tetromino position getHorizontalColliders checkLeftCollision
  let public willRightCollide board tetromino position = willCollide board tetromino position getHorizontalColliders checkRightCollision


Checking for Gameover
The gameover check is a combination of collision checking and vertical Point on the board, for example:
  • If the Y coordinate is 0 (top of the board), and we have detected a collision then the game ends.
Add the following code to Tetris.fs; below the willRightCollide function:
C#:
  let public isGameOver board tetromino position =
    willVerticalCollide board tetromino position && position.Y = 0
 
Last edited:
Removing full lines from the board
Removing full lines is a simple matter of checking if all the characters in a particular row aren't a space character using List.forall; stripping full lines by using head (rh) and tail (rf) recursion to drop the rh rows that are full and prepend the rows that are not.

Add the following code to Tetris.fs; below the isGameOver function:
C#:
  let public removeLines board =
    let rec iterY xss =
      match xss with
      | [] -> []
      | rh :: rf -> if List.forall (fun x -> x <> ' ') rh then iterY rf else rh :: iterY rf
    iterY board

Adding empty lines to augment the board
Adding empty lines to make up for removed lines; requires a simple List construction and then prepending that list to the board.

Add the following code to Tetris.fs; below the removeLines function:
C#:
  let public addLines (size: Point) board =
    let addRows = [ for _ in 0..(size.Y - 1 - (List.length board)) do yield [ for _ in 0..(size.X - 1) do yield ' ' ] ]
    if List.length addRows > 0 then addRows @ board  else board
 
Compute the score
The last part of the logic engine is to compute the score using the scoring algorithm for Tetris on the Nintendo.
759372
We can express this as a pattern match over two parameters; level and full lines removed.

Add the following code to Tetris.fs; below the addLines function:
C#:
  let public calcScore level lines =
    let result =
      match lines with
      | 1 -> int (40. * (float level + 1.))
      | 2 -> int (40. * 2.5 * (float level + 1.))
      | n -> int (List.fold (fun a e -> a * float e) 100. [3..n] * (float level + 1.))
    int result

The next tranche of code will focus on building out the view / controller parts using first an imperative style to focus on achieving a functionally working Tetris. The style will be a typical beginner example of throw it all in the controller to create the MVC (massive view controller).

After which we'll start looking at how we use functional techniques and structures to unravel a large view / controller and create an easy to achieve separation between model, view and controller.
 
Last edited:
Building the all-in-one Model / View / Controller

Game state variables

We need to define a number of variables to track the current state of Tetris; these are the variables we'll manipulate during the game loop to affect the draw.

Currently we have the following variables:
  • size : size of the game board
  • board : current state of the game board
Let's define the remainder that we'll need:
  • mBoard : a copy of board that we'll use to temporarily merge the current tetromino as it continues to move; only once the tetromino collides with existing layers will the tetromino be permanently merged with the board variable.
  • tetromino : current falling tetromino.
  • nextTetromino : the next tetromino in the queue.
  • tPos : the tetromino current Point offset on the board.
  • dropDuration : the drop speed of the tetromino in milliseconds
  • lines : the number of full lines that have been removed
  • level : the current game level
  • score : computed score; tied to lines and level
  • speedPerLevel : speed increase per level; adjusts the dropDuration
  • lastKey : TimeSpan of the last key press; used to slow down the key stroke detection
  • lastFall : The last TimeSpan when the tetromino's tPos Y coordinate was increased i.e. to track the animation of the fall. This works in conjunction with the dropDuration.
Add the following code to the Controller.fs below the line :
C#:
  let mutable mBoard = board
  let mutable tetromino = Seq.take 1 Tetrominos |> Seq.item 0
  let mutable nextTetromino = Seq.take 1 Tetrominos |> Seq.item 0
  let mutable tPos = new Point(4, 0)
  let mutable dropDuration = 950
  let mutable lines = 0
  let mutable level = 0
  let mutable score = 0
  let speedPerLevel = 55
  let mutable lastKey = Unchecked.defaultof<TimeSpan>
  let mutable lastFall = Unchecked.defaultof<TimeSpan>


Animate the fall of the tetromino:
Add the following code to the Update method in Controller.fs; replace the following lines:
C#:
board <- merge leftPeriscope (new Point(2, 2)) (fun a -> a <> ' ') board
board <- merge (rotateLeft rightPeriscope)  (new Point(2, 10)) (fun a -> a <> ' ') board

With these lines:
C#:
  if (gameTime.TotalGameTime - lastFall).Milliseconds >= dropDuration then
      lastFall <- gameTime.TotalGameTime
      tPos.Y <- tPos.Y + 1

    mBoard <- merge tetromino tPos (fun a -> a <> ' ') board
Here we are evaluating the elapsed game time in milliseconds between the lastFall TimeSpan exceeds the dropDuration; this determines the drop speed of the tetromino i.e. its frame rate, in which case we increase its Y value to continue the drop.

For the merge we are using the mBoard variable which is the board merged with the current tetromino position on the board. The board essentially only reflects the fixed lines; whereas mBoard includes the tetromino is motion.

In the Draw method, change the following line from board to mBoard:
C#:
//let c = board.[y].[x]

// Change to ...
let c = mBoard.[y].[x]

Run the application and you'll see a tetromino falling from the top of the board and disappearing off the board at the bottom.
759438
In the next post we'll add some control; left / right movement and rotation.
 
Last edited:
Animate Left / Right and Drop
Similar to the fall; we reduce the speed of keystroke detection to once every 100 milliseconds, using the lastKey TimeSpan.

Adding keystroke detection for left / right
Adding the following code to the Controller.fs, below the line tPos.Y <- tPos.Y + 1 and before the line mBoard <- merge tetromino tPos (fun a -> a <> ' ') board
C#:
    if (gameTime.TotalGameTime - lastKey).Milliseconds > 100 then
      lastKey <- gameTime.TotalGameTime
      let keyboard = Keyboard.GetState()
      if keyboard.IsKeyDown(Keys.Left) then
        tPos.X <- tPos.X - 1
      if keyboard.IsKeyDown(Keys.Right) then
        tPos.X <- tPos.X + 1
We start by evaluating if the time elapsed since our lastKey TimeSpan is greater than 100 milliseconds; if so we get update the lastKey TimeSpan, and the retrieve the current keyboard state.
If the Keys.Left is down, we decrement the X value of tPos, and if the Keys.Right is down, we increment the Y value.

Running the application; we now have the ability to move the tetromino left and right with the arrow keys.
759466

Adding bounds checking for the left / right movement
At the moment the left / right keypresses can result in the tetromino moving completely off the board; whereas in tetris, the tetromino should be prevented from exiting the board either on the left or right.

Change the code for the left / right key presses to the following:
C#:
      if keyboard.IsKeyDown(Keys.Left) then
        tPos.X <- if (willLeftCollide board tetromino tPos) then tPos.X else tPos.X - 1
      if keyboard.IsKeyDown(Keys.Right) then
        tPos.X <- if (willRightCollide board tetromino tPos) then tPos.X else tPos.X + 1
This adds ternary style evaluation of the position of the tetromino on the board and will only adjust the value of X if the tetromino is not colliding horizontally with either the bounds of the board or any other fixture on the board.

Animating the drop
In tetris, we have the ability to drop a tetromino once we are happy with its alignment. To accomplish we'll use the Keys.Down and adjust the dropDuration interval.

Add the following code below the left / right key press code.
C#:
        if keyboard.IsKeyDown(Keys.Down) then
          dropDuration <- 5
Changing the dropDuration, substantially decreases the interval when the tetromino's Y value is adjusted for the fall; which visually achieves the effect we need.

759480


Recap
For clarity, let's recap with a full copy of the Controller.fs as it currently stands:
C#:
//
// Controller.fs
//
namespace Endofunk
open System
open Microsoft.Xna.Framework
open Microsoft.Xna.Framework.Graphics
open Microsoft.Xna.Framework.Input
open TetrisShared.Tetromino
open TetrisShared.Tetris

type Controller () as this =
  inherit Game()

  let graphics = new GraphicsDeviceManager(this)
  let mutable spriteBatch = Unchecked.defaultof<SpriteBatch>
  let mutable block = Unchecked.defaultof<Texture2D>
  let mutable empty = Unchecked.defaultof<Texture2D>
  let mutable logo = Unchecked.defaultof<Texture2D>
  let size = new Point(10, 20)
  let mutable board = [ for _ in 1..size.Y do yield [ for _ in 1..size.X do yield ' ' ] ]
  let mutable mBoard = board
  let mutable tetromino = Seq.take 1 Tetrominos |> Seq.item 0
  let mutable nextTetromino = Seq.take 1 Tetrominos |> Seq.item 0
  let mutable tPos = new Point(4, 0)
  let mutable dropDuration = 950
  let mutable lines = 0
  let mutable level = 0
  let mutable score = 0
  let speedPerLevel = 55
  let mutable lastKey = Unchecked.defaultof<TimeSpan>
  let mutable lastFall = Unchecked.defaultof<TimeSpan>

  do
    this.Content.RootDirectory <- "Content"
    this.IsMouseVisible <- true
    graphics.PreferredBackBufferWidth <- 480
    graphics.PreferredBackBufferHeight <- 640
  override this.Initialize() =
    base.Initialize()

  override this.LoadContent() =
    spriteBatch <- new SpriteBatch(this.GraphicsDevice)

    // TODO: use this.LoadContent to load your game content here
    block <- this.Content.Load<Texture2D>("Block")
    empty <- this.Content.Load<Texture2D>("Empty")
    logo <- this.Content.Load<Texture2D>("Logo")

  override this.Update (gameTime) =
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back = ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) then this.Exit()

    // TODO: use this.UpdateContent to load your game content here
    if (gameTime.TotalGameTime - lastFall).Milliseconds >= dropDuration then
      lastFall <- gameTime.TotalGameTime
      tPos.Y <- tPos.Y + 1

    if (gameTime.TotalGameTime - lastKey).Milliseconds > 100 then
      lastKey <- gameTime.TotalGameTime
      let keyboard = Keyboard.GetState()
      if keyboard.IsKeyDown(Keys.Left) then
        tPos.X <- if (willLeftCollide board tetromino tPos) then tPos.X else tPos.X - 1
      if keyboard.IsKeyDown(Keys.Right) then
        tPos.X <- if (willRightCollide board tetromino tPos) then tPos.X else tPos.X + 1
      if keyboard.IsKeyDown(Keys.Down) then
        dropDuration <- 5

    mBoard <- merge tetromino tPos (fun a -> a <> ' ') board
    base.Update(gameTime)

  override this.Draw (gameTime) =
    this.GraphicsDevice.Clear Color.Black

    spriteBatch.Begin()
    // TODO: Add your drawing code here
    for y in 0..(size.Y - 1) do
      for x in 0..(size.X - 1) do
        let c = mBoard.[y].[x]
        let p = new Point(x * block.Width + 10, y * block.Height + 20)
        if c <> ' ' then
          spriteBatch.Draw(block, new Rectangle(p.X, p.Y, block.Width, block.Height), (char2Color c) * 1.0f)
        else spriteBatch.Draw(empty, new Rectangle(p.X, p.Y, empty.Width, empty.Height), Color.DarkSlateGray * 0.2f)
    spriteBatch.End()
    base.Draw(gameTime)


Next post
In the next post, we focus on rotating the tetromino using Keys.Up (up arrow key), and implementing vertical bounds / collision checks.
 
Last edited:
Animating Rotation and Detecting Vertical Collisions

Rotating the Tetromino

Add the following code below the down key press code:
C#:
      if keyboard.IsKeyDown(Keys.Up) then
        tetromino <- rotateLeft tetromino
        if (xLen tetromino + tPos.X) > xLen board
        then tPos.X <- tPos.X + (xLen board - (xLen tetromino + tPos.X))
The above code mutates the tetromino rotating it left; the remainder of the code adjusts for the offset differences resultant from the rotation of the asymmetrical tetrominos; without the adjustment, many of the tetrominos would be rendered out of bounds after a rotation.

Detecting Vertical Collisions
Vertical collisions are a primary part of what typifies the Tetris game play; vertical collisions can occur between other tetromino fixtures on the board and/or the bottom of the board.

When a vertical collision is detected, a few things need to occur:
  1. Merge the tetromino with the board.
  2. Update mBoard to the same value as board.
  3. Assign the nextTetromino value to tetromino.
  4. Assign the next sequence of tetromino to nextTetromino.
  5. Assign the default starting value to tPos; i.e. new Point(4, 0) .
  6. Recompute the dropDuration.
  7. Check if the game is over -- if true; reset variables to default values.
If a vertical collision is not detected a few things need to occur:
  1. Remove full lines on the board
  2. Augment the board by the number of removed lines
  3. Compute the number of lines removed
  4. Compute the level
  5. Compute the score
Replace the following line mBoard <- merge tetromino tPos (fun a -> a <> ' ') board in the Update method with the code below:
C#:
    if (willVerticalCollide board tetromino tPos) || tPos.Y >= (List.length board - List.length tetromino) then
      board <- merge tetromino tPos (fun a -> a <> ' ') board
      mBoard <- board
      tetromino <- nextTetromino
      nextTetromino <- Seq.take 1 Tetrominos |> Seq.item 0
      tPos <- new Point(4, 0)
      dropDuration <- 950 - (level * speedPerLevel)
      if isGameOver mBoard tetromino tPos then
        board <- [ for _ in 1..size.Y do yield [ for _ in 1..size.X do yield ' ' ] ]
        mBoard <- board
        lines <- 0
        level <- 0
        score <- 0
        dropDuration <- 950
    else
      board <- removeLines board
      let linesRemoved = size.Y - List.length board
      lines <- lines + linesRemoved
      if linesRemoved > 0 then
        score <- score + calcScore level linesRemoved
      board <- board |> addLines size
      mBoard <- merge tetromino tPos (fun a -> a <> ' ') board
      level <- lines / 10
This final addition completes the tetris gameplay, barring the display of the nextTetromino, the totals for score, level and lines, which will be covered in the next post.

759590


Recap
For clarity, let's recap with a full copy of the Controller.fs as it currently stands:
C#:
//
// Controller.fs
//
namespace Endofunk
open System
open Microsoft.Xna.Framework
open Microsoft.Xna.Framework.Graphics
open Microsoft.Xna.Framework.Input
open TetrisShared.Tetromino
open TetrisShared.Tetris

type Controller () as this =
  inherit Game()

  let graphics = new GraphicsDeviceManager(this)
  let mutable spriteBatch = Unchecked.defaultof<SpriteBatch>
  let mutable block = Unchecked.defaultof<Texture2D>
  let mutable empty = Unchecked.defaultof<Texture2D>
  let mutable logo = Unchecked.defaultof<Texture2D>
  let size = new Point(10, 20)
  let mutable board = [ for _ in 1..size.Y do yield [ for _ in 1..size.X do yield ' ' ] ]
  let mutable mBoard = board
  let mutable tetromino = Seq.take 1 Tetrominos |> Seq.item 0
  let mutable nextTetromino = Seq.take 1 Tetrominos |> Seq.item 0
  let mutable tPos = new Point(4, 0)
  let mutable dropDuration = 950
  let mutable lines = 0
  let mutable level = 0
  let mutable score = 0
  let speedPerLevel = 55
  let mutable lastKey = Unchecked.defaultof<TimeSpan>
  let mutable lastFall = Unchecked.defaultof<TimeSpan>

  do
    this.Content.RootDirectory <- "Content"
    this.IsMouseVisible <- true
    graphics.PreferredBackBufferWidth <- 480
    graphics.PreferredBackBufferHeight <- 640
  override this.Initialize() =
    base.Initialize()

  override this.LoadContent() =
    spriteBatch <- new SpriteBatch(this.GraphicsDevice)

    // TODO: use this.LoadContent to load your game content here
    block <- this.Content.Load<Texture2D>("Block")
    empty <- this.Content.Load<Texture2D>("Empty")
    logo <- this.Content.Load<Texture2D>("Logo")

  override this.Update (gameTime) =
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back = ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) then this.Exit()

    // TODO: use this.UpdateContent to load your game content here
    if (gameTime.TotalGameTime - lastFall).Milliseconds >= dropDuration then
      lastFall <- gameTime.TotalGameTime
      tPos.Y <- tPos.Y + 1

    if (gameTime.TotalGameTime - lastKey).Milliseconds > 100 then
      lastKey <- gameTime.TotalGameTime
      let keyboard = Keyboard.GetState()
      if keyboard.IsKeyDown(Keys.Left) then
        tPos.X <- if (willLeftCollide board tetromino tPos) then tPos.X else tPos.X - 1
      if keyboard.IsKeyDown(Keys.Right) then
        tPos.X <- if (willRightCollide board tetromino tPos) then tPos.X else tPos.X + 1
      if keyboard.IsKeyDown(Keys.Down) then
        dropDuration <- 5
      if keyboard.IsKeyDown(Keys.Up) then
        tetromino <- rotateLeft tetromino
        if (xLen tetromino + tPos.X) > xLen board
        then tPos.X <- tPos.X + (xLen board - (xLen tetromino + tPos.X))

    if (willVerticalCollide board tetromino tPos) || tPos.Y >= (List.length board - List.length tetromino) then
      board <- merge tetromino tPos (fun a -> a <> ' ') board
      mBoard <- board
      tetromino <- nextTetromino
      nextTetromino <- Seq.take 1 Tetrominos |> Seq.item 0
      tPos <- new Point(4, 0)
      dropDuration <- 950 - (level * speedPerLevel)
      if isGameOver mBoard tetromino tPos then
        board <- [ for _ in 1..size.Y do yield [ for _ in 1..size.X do yield ' ' ] ]
        mBoard <- board
        lines <- 0
        level <- 0
        score <- 0
        dropDuration <- 950
    else
      board <- removeLines board
      let linesRemoved = size.Y - List.length board
      lines <- lines + linesRemoved
      if linesRemoved > 0 then
        score <- score + calcScore level linesRemoved
      board <- board |> addLines size
      mBoard <- merge tetromino tPos (fun a -> a <> ' ') board
      level <- lines / 10
    
    base.Update(gameTime)
  
  override this.Draw (gameTime) =
    this.GraphicsDevice.Clear Color.Black

    spriteBatch.Begin()
    // TODO: Add your drawing code here
    for y in 0..(size.Y - 1) do
      for x in 0..(size.X - 1) do
        let c = mBoard.[y].[x]
        let p = new Point(x * block.Width + 10, y * block.Height + 20)
        if c <> ' ' then
          spriteBatch.Draw(block, new Rectangle(p.X, p.Y, block.Width, block.Height), (char2Color c) * 1.0f)
        else spriteBatch.Draw(empty, new Rectangle(p.X, p.Y, empty.Width, empty.Height), Color.DarkSlateGray * 0.2f)
    spriteBatch.End()
    base.Draw(gameTime)
 
Last edited:
Droid, just thank you, this is incredible.
My pleasure; glad you like it.
I'll wrap up the imperative code today; complete with scoring, next tetromino preview and startup logo.

After Christmas and into the new year I'll post the steps on how we convert the imperative all-in-one MVC to a more maintainable / strict separation of code between the model, the view and the controller by building an actor based implementation of the Redux state engine, that maintains state on a background thread.

PM if you need any help converting the F# code to another language.
 
Last edited:
My pleasure; glad you like it.
I'll wrap up the imperative code today; complete with scoring, next tetromino preview and startup logo.

After Christmas and into the new year I'll post the steps on how we convert the imperative all-in-one MVC to a more maintainable / strict separation of code between the model, the view and the controller by building an actor based implementation of the Redux state engine, that maintains state on a background thread.

PM if you need any help converting the F# code to another language.
So far, it has been quite easy to covert to js classes, only thing I'm doing differently is actually generating the tetrominos in a pixel buffer from the "shape" matrix. And then leveraging DOM for the score/status display.

Your code is a breeze to follow, thanks again!
 
Display the next Tetromino
Add the following code to Controller.fs after spriteBatch.Begin() to render the next queued tetromino:
C#:
    for y in yRange nextTetromino do
      for x in xRange nextTetromino do
        let c = nextTetromino.[y].[x]
        let p = new Point(x * block.Width + 346, y * block.Height + 70)
        if c <> ' ' then
          spriteBatch.Draw(block, new Rectangle(p.X, p.Y, block.Width, block.Height), (char2Color c) * 1.0f)
        else spriteBatch.Draw(empty, new Rectangle(p.X, p.Y, empty.Width, empty.Height), Color.Black * 0.2f)


Add Spritefont asset files
Add the https://www.wfonts.com/font/space-invaders to the Content folder.
Create the following three files in the Content folder with the following text in sequence:

Font16.spritefont
Code:
<?xml version="1.0" encoding="utf-8"?>
<!--
This file contains an xml description of a font, and will be read by the XNA
Framework Content Pipeline. Follow the comments to customize the appearance
of the font in your game, and to change the characters which are available to draw
with.
-->
<XnaContent xmlns:Graphics="Microsoft.Xna.Framework.Content.Pipeline.Graphics">
  <Asset Type="Graphics:FontDescription">

    <!--
    Modify this string to change the font that will be imported.
    -->
    <FontName>space_invaders</FontName>

    <!--
    Size is a float value, measured in points. Modify this value to change
    the size of the font.
    -->
    <Size>16</Size>

    <!--
    Spacing is a float value, measured in pixels. Modify this value to change
    the amount of spacing in between characters.
    -->
    <Spacing>0</Spacing>

    <!--
    UseKerning controls the layout of the font. If this value is true, kerning information
    will be used when placing characters.
    -->
    <UseKerning>true</UseKerning>

    <!--
    Style controls the style of the font. Valid entries are "Regular", "Bold", "Italic",
    and "Bold, Italic", and are case sensitive.
    -->
    <Style>Regular</Style>

    <!--
    If you uncomment this line, the default character will be substituted if you draw
    or measure text that contains characters which were not included in the font.
    -->
    <!-- <DefaultCharacter>*</DefaultCharacter> -->

    <!--
    CharacterRegions control what letters are available in the font. Every
    character from Start to End will be built and made available for drawing. The
    default range is from 32, (ASCII space), to 126, ('~'), covering the basic Latin
    character set. The characters are ordered according to the Unicode standard.
    See the documentation for more information.
    -->
    <CharacterRegions>
      <CharacterRegion>
        <Start>&#32;</Start>
        <End>&#126;</End>
      </CharacterRegion>
      <CharacterRegion>
        <Start>°</Start>
        <End>°</End>
      </CharacterRegion>
    </CharacterRegions>
  </Asset>
</XnaContent>

Font20.spritefont
Code:
<?xml version="1.0" encoding="utf-8"?>
<!--
This file contains an xml description of a font, and will be read by the XNA
Framework Content Pipeline. Follow the comments to customize the appearance
of the font in your game, and to change the characters which are available to draw
with.
-->
<XnaContent xmlns:Graphics="Microsoft.Xna.Framework.Content.Pipeline.Graphics">
  <Asset Type="Graphics:FontDescription">

    <!--
    Modify this string to change the font that will be imported.
    -->
    <FontName>space_invaders</FontName>

    <!--
    Size is a float value, measured in points. Modify this value to change
    the size of the font.
    -->
    <Size>20</Size>

    <!--
    Spacing is a float value, measured in pixels. Modify this value to change
    the amount of spacing in between characters.
    -->
    <Spacing>0</Spacing>

    <!--
    UseKerning controls the layout of the font. If this value is true, kerning information
    will be used when placing characters.
    -->
    <UseKerning>true</UseKerning>

    <!--
    Style controls the style of the font. Valid entries are "Regular", "Bold", "Italic",
    and "Bold, Italic", and are case sensitive.
    -->
    <Style>Regular</Style>

    <!--
    If you uncomment this line, the default character will be substituted if you draw
    or measure text that contains characters which were not included in the font.
    -->
    <!-- <DefaultCharacter>*</DefaultCharacter> -->

    <!--
    CharacterRegions control what letters are available in the font. Every
    character from Start to End will be built and made available for drawing. The
    default range is from 32, (ASCII space), to 126, ('~'), covering the basic Latin
    character set. The characters are ordered according to the Unicode standard.
    See the documentation for more information.
    -->
    <CharacterRegions>
      <CharacterRegion>
        <Start>&#32;</Start>
        <End>&#126;</End>
      </CharacterRegion>
      <CharacterRegion>
        <Start>°</Start>
        <End>°</End>
      </CharacterRegion>
    </CharacterRegions>
  </Asset>
</XnaContent>

Font22.spritefont
Code:
<?xml version="1.0" encoding="utf-8"?>
<!--
This file contains an xml description of a font, and will be read by the XNA
Framework Content Pipeline. Follow the comments to customize the appearance
of the font in your game, and to change the characters which are available to draw
with.
-->
<XnaContent xmlns:Graphics="Microsoft.Xna.Framework.Content.Pipeline.Graphics">
  <Asset Type="Graphics:FontDescription">

    <!--
    Modify this string to change the font that will be imported.
    -->
    <FontName>space_invaders</FontName>

    <!--
    Size is a float value, measured in points. Modify this value to change
    the size of the font.
    -->
    <Size>22</Size>

    <!--
    Spacing is a float value, measured in pixels. Modify this value to change
    the amount of spacing in between characters.
    -->
    <Spacing>0</Spacing>

    <!--
    UseKerning controls the layout of the font. If this value is true, kerning information
    will be used when placing characters.
    -->
    <UseKerning>true</UseKerning>

    <!--
    Style controls the style of the font. Valid entries are "Regular", "Bold", "Italic",
    and "Bold, Italic", and are case sensitive.
    -->
    <Style>Regular</Style>

    <!--
    If you uncomment this line, the default character will be substituted if you draw
    or measure text that contains characters which were not included in the font.
    -->
    <!-- <DefaultCharacter>*</DefaultCharacter> -->

    <!--
    CharacterRegions control what letters are available in the font. Every
    character from Start to End will be built and made available for drawing. The
    default range is from 32, (ASCII space), to 126, ('~'), covering the basic Latin
    character set. The characters are ordered according to the Unicode standard.
    See the documentation for more information.
    -->
    <CharacterRegions>
      <CharacterRegion>
        <Start>&#32;</Start>
        <End>&#126;</End>
      </CharacterRegion>
      <CharacterRegion>
        <Start>°</Start>
        <End>°</End>
      </CharacterRegion>
    </CharacterRegions>
  </Asset>
</XnaContent>

Append the following changes to the Content.mgcb file
Code:
#begin Font16.spritefont
/importer:FontDescriptionImporter
/processor:FontDescriptionProcessor
/processorParam:PremultiplyAlpha=True
/processorParam:TextureFormat=Compressed
/build:Font16.spritefont

#begin Font20.spritefont
/importer:FontDescriptionImporter
/processor:FontDescriptionProcessor
/processorParam:PremultiplyAlpha=True
/processorParam:TextureFormat=Compressed
/build:Font20.spritefont

#begin Font22.spritefont
/importer:FontDescriptionImporter
/processor:FontDescriptionProcessor
/processorParam:PremultiplyAlpha=True
/processorParam:TextureFormat=Compressed
/build:Font22.spritefont

Add the following code to the Controller.fs after the following line let mutable logo = Unchecked.defaultof<Texture2D> :
C#:
  let mutable f16 = Unchecked.defaultof<SpriteFont>
  let mutable f20 = Unchecked.defaultof<SpriteFont>
  let mutable f22 = Unchecked.defaultof<SpriteFont>

Add the following code to the Controller.fs after the following line logo <- this.Content.Load<Texture2D>("Logo"):
C#:
  let mutable f16 = Unchecked.defaultof<SpriteFont>
  let mutable f20 = Unchecked.defaultof<SpriteFont>
  let mutable f22 = Unchecked.defaultof<SpriteFont>

Add the following code to the Controller.fs before the following line spriteBatch.End():
C#:
      spriteBatch.DrawString(f20, "NEXT", new Vector2(355.0f, 20.0f), Color.Yellow * 0.7f)
      spriteBatch.DrawString(f16, "SCORE:", new Vector2(320.0f, 207.0f), Color.Yellow * 0.7f)
      spriteBatch.DrawString(f16, sprintf "%08d" score, new Vector2(320.0f, 235.0f), Color.White * 0.7f)
      spriteBatch.DrawString(f16, "LEVEL", new Vector2(320.0f, 281.0f), Color.Yellow * 0.7f)
      spriteBatch.DrawString(f22, sprintf "%02d" level, new Vector2(405.0f, 276.0f), Color.White * 0.7f)
      spriteBatch.DrawString(f16, "LINES", new Vector2(320.0f, 322.0f), Color.Yellow * 0.7f)
      spriteBatch.DrawString(f22, sprintf "%03d" lines, new Vector2(405.0f, 317.0f), Color.White * 0.7f)

759788
 
Last edited:
Recap
For clarity, let's recap with a full copy of the Controller.fs as it currently stands:
C#:
//
// Controller.fs
//
namespace Endofunk
open System
open Microsoft.Xna.Framework
open Microsoft.Xna.Framework.Graphics
open Microsoft.Xna.Framework.Input
open TetrisShared.Tetromino
open TetrisShared.Tetris

type Controller () as this =
  inherit Game()

  let graphics = new GraphicsDeviceManager(this)
  let mutable spriteBatch = Unchecked.defaultof<SpriteBatch>
  let mutable block = Unchecked.defaultof<Texture2D>
  let mutable empty = Unchecked.defaultof<Texture2D>
  let mutable logo = Unchecked.defaultof<Texture2D>
  let mutable f16 = Unchecked.defaultof<SpriteFont>
  let mutable f20 = Unchecked.defaultof<SpriteFont>
  let mutable f22 = Unchecked.defaultof<SpriteFont>
  let size = new Point(10, 20)
  let mutable board = [ for _ in 1..size.Y do yield [ for _ in 1..size.X do yield ' ' ] ]
  let mutable mBoard = board
  let mutable tetromino = Seq.take 1 Tetrominos |> Seq.item 0
  let mutable nextTetromino = Seq.take 1 Tetrominos |> Seq.item 0
  let mutable tPos = new Point(4, 0)
  let mutable dropDuration = 950
  let mutable lines = 0
  let mutable level = 0
  let mutable score = 0
  let speedPerLevel = 55
  let mutable lastKey = Unchecked.defaultof<TimeSpan>
  let mutable lastFall = Unchecked.defaultof<TimeSpan>

  do
    this.Content.RootDirectory <- "Content"
    this.IsMouseVisible <- true
    graphics.PreferredBackBufferWidth <- 480
    graphics.PreferredBackBufferHeight <- 640
  override this.Initialize() =
    base.Initialize()

  override this.LoadContent() =
    spriteBatch <- new SpriteBatch(this.GraphicsDevice)

    // TODO: use this.LoadContent to load your game content here
    block <- this.Content.Load<Texture2D>("Block")
    empty <- this.Content.Load<Texture2D>("Empty")
    logo <- this.Content.Load<Texture2D>("Logo")
    f16 <- this.Content.Load<SpriteFont>("Font16")
    f20 <- this.Content.Load<SpriteFont>("Font20")
    f22 <- this.Content.Load<SpriteFont>("Font22")

  override this.Update (gameTime) =
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back = ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape)) then this.Exit()

    // TODO: use this.UpdateContent to load your game content here
    if (gameTime.TotalGameTime - lastFall).Milliseconds >= dropDuration then
      lastFall <- gameTime.TotalGameTime
      tPos.Y <- tPos.Y + 1

    if (gameTime.TotalGameTime - lastKey).Milliseconds > 100 then
      lastKey <- gameTime.TotalGameTime
      let keyboard = Keyboard.GetState()
      if keyboard.IsKeyDown(Keys.Left) then
        tPos.X <- if (willLeftCollide board tetromino tPos) then tPos.X else tPos.X - 1
      if keyboard.IsKeyDown(Keys.Right) then
        tPos.X <- if (willRightCollide board tetromino tPos) then tPos.X else tPos.X + 1
      if keyboard.IsKeyDown(Keys.Down) then
        dropDuration <- 5
      if keyboard.IsKeyDown(Keys.Up) then
        tetromino <- rotateLeft tetromino
        if (xLen tetromino + tPos.X) > xLen board
        then tPos.X <- tPos.X + (xLen board - (xLen tetromino + tPos.X))

    if (willVerticalCollide board tetromino tPos) || tPos.Y >= (List.length board - List.length tetromino) then
      board <- merge tetromino tPos (fun a -> a <> ' ') board
      mBoard <- board
      tetromino <- nextTetromino
      nextTetromino <- Seq.take 1 Tetrominos |> Seq.item 0
      tPos <- new Point(4, 0)
      dropDuration <- 950 - (level * speedPerLevel)
      if isGameOver mBoard tetromino tPos then
        board <- [ for _ in 1..size.Y do yield [ for _ in 1..size.X do yield ' ' ] ]
        mBoard <- board
        lines <- 0
        level <- 0
        score <- 0
        dropDuration <- 950
    else
      board <- removeLines board
      let linesRemoved = size.Y - List.length board
      lines <- lines + linesRemoved
      if linesRemoved > 0 then
        score <- score + calcScore level linesRemoved
      board <- board |> addLines size
      mBoard <- merge tetromino tPos (fun a -> a <> ' ') board
      level <- lines / 10
    
    base.Update(gameTime)
  
  override this.Draw (gameTime) =
    this.GraphicsDevice.Clear Color.Black

    spriteBatch.Begin()
    // TODO: Add your drawing code here
    for y in yRange nextTetromino do
      for x in xRange nextTetromino do
        let c = nextTetromino.[y].[x]
        let p = new Point(x * block.Width + 346, y * block.Height + 70)
        if c <> ' ' then
          spriteBatch.Draw(block, new Rectangle(p.X, p.Y, block.Width, block.Height), (char2Color c) * 1.0f)
        else spriteBatch.Draw(empty, new Rectangle(p.X, p.Y, empty.Width, empty.Height), Color.Black * 0.2f)
  
    for y in 0..(size.Y - 1) do
      for x in 0..(size.X - 1) do
        let c = mBoard.[y].[x]
        let p = new Point(x * block.Width + 10, y * block.Height + 20)
        if c <> ' ' then
          spriteBatch.Draw(block, new Rectangle(p.X, p.Y, block.Width, block.Height), (char2Color c) * 1.0f)
        else spriteBatch.Draw(empty, new Rectangle(p.X, p.Y, empty.Width, empty.Height), Color.DarkSlateGray * 0.2f)

      spriteBatch.DrawString(f20, "NEXT", new Vector2(355.0f, 20.0f), Color.Yellow * 0.7f)
      spriteBatch.DrawString(f16, "SCORE:", new Vector2(320.0f, 207.0f), Color.Yellow * 0.7f)
      spriteBatch.DrawString(f16, sprintf "%08d" score, new Vector2(320.0f, 235.0f), Color.White * 0.7f)
      spriteBatch.DrawString(f16, "LEVEL", new Vector2(320.0f, 281.0f), Color.Yellow * 0.7f)
      spriteBatch.DrawString(f22, sprintf "%02d" level, new Vector2(405.0f, 276.0f), Color.White * 0.7f)
      spriteBatch.DrawString(f16, "LINES", new Vector2(320.0f, 322.0f), Color.Yellow * 0.7f)
      spriteBatch.DrawString(f22, sprintf "%03d" lines, new Vector2(405.0f, 317.0f), Color.White * 0.7f)
    spriteBatch.End()
    base.Draw(gameTime)


Next Post
That's is for now; we have pretty much a working version of Tetris. In the next set of posts we'll focus on transitioning the imperative style of code to a more declarative / functional style -- with the end goal of making Tetris not only easier to manage, but also easier to change, bug fix and extend.
 
Last edited:
Top
Sign up to the MyBroadband newsletter
X