LiveCode Tetris

Posted by Scott McDonald

Tetris. The original falling block, fill a row game. The game that helped ensure the success of the GameBoy. This game is a true classic and still has fans. (Addicts?) Some of the previous games on LiveCode Game Developer (23 Matches and 3D Isometric Maze) are more demonstrations of technique than compelling games. I made them, tested them for a few minutes and then moved on to something more interesting. But Tetris is different. This is the first game from this blog that I have repeatedly wanted to play, and it required less than 350 lines of LiveCode code.

In this game you will see:

  • Collision detection based on a tile grid
  • A compact way of representing and rotating the blocks
  • A game world larger than it needs to be to simplify coding
  • Storing of high scores to add incentive
  • Addictive gameplay

While I can't take credit for the last point, the other points are in the code you can examine below.

A little disclaimer about the second point: A compact way of representing and rotating the falling blocks. While the code that actually rotates the tetrominoes (each pattern of falling blocks) is small, about 5 lines, setting up the data to achieve this was a real pain. Too much time was spent with sheets of grid paper and pencil figuring out the numbers for each transformation. If I was to code it again from scratch, I would probably use more code with a more general purpose approach of matrices.

StartGame begins by removing any messages that are in the message queue. This is a good idea when starting a game, because if there are already messages pending that call your GameLoop (which can happen during development) if you don't first remove them the game animation will be unpredictable. Then the graphics from the previous run of the game are deleted.

Each group of 4 falling blocks is then defined by calling CreateBlockDefinition and CreateBlockTransform. How these work and the format of the data will be discussed later when these handlers are listed in full.

The sPlayArea array represents the visible area where the blocks are on the screen. This area is 10 blocks wide and 20 blocks high. Each element of the array is set to empty. An empty element means there is no block at that point in the array. Then there are two extra loops that fill sPlayArea with a 0 for the column and row outside of the visible area. These extra elements in the array simplifies the later code.

In Tetris the falling blocks stop moving when they come into contact with the stationary blocks below. So there needs to be a handler that detects contact and stops the motion of the blocks that collide. But what about stopping the blocks from falling through the bottom of the screen, or from moving sideways out of the play area? Another handler could be used to detect motion outside of the play area. With code that handles the special cases when the column is less than 1 or greater than 10, or the row is greater than 20, but this means more complexity.

Instead by extending the sPlayArea by 1 along the left, bottom and right edges and putting 0 into those elements of the array, no extra code is required to check when the blocks try to leave the visible area or hit the bottom.

Then some variables are initialized, including the high score list, and the first set of 4 blocks about to fall are created before calling GameLoop.

command StartGame
  local pendMsg,blockID
  put the pendingmessages into pendMsg
  repeat for each line loopLine in pendMsg
    cancel item 1 of loopLine
  end repeat
  
  lock screen
  repeat with loopIndex = the number of graphics down to 1
    if the pvBlock of graphic loopIndex then delete graphic loopIndex
  end repeat
  
  -- block
  CreateBlockDefinition 1,"0,0, 1,0, 0,1, -1,0"
  CreateBlockTransform 1,"0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0"
  -- line
  CreateBlockDefinition 2,"0,0, 1,0, 1,0, 1,0"
  CreateBlockTransform 2,"1,-1,0,0,-1,1,-2,2, 2,1,1,0,0,-1,-1,-2, -2,2,-1,1,0,0,1,-1, -1,-2,0,-1,1,0,2,1"
  -- s
  CreateBlockDefinition 3,"0,0, 1,0, 0,-1, 1,0"
  CreateBlockTransform 3,"1,-1,0,0,1,1,0,2, 1,1,0,0,-1,1,-2,0, -1,1,0,0,-1,-1,0,-2, -1,-1,0,0,1,-1,2,0"
  -- z
  CreateBlockDefinition 4,"0,0, 1,0, 0,1, 1,0"
  CreateBlockTransform 4,"2,-1,1,0,0,-1,-1,0, 0,2,-1,1,0,0,-1,-1, -2,0,-1,-1,0,0,1,-1, 0,-1,1,0,0,1,1,2"
  -- t
  CreateBlockDefinition 5,"0,0, 1,0, 1,0, -1,1"
  CreateBlockTransform 5,"1,-1,0,0,-1,1,-1,-1, 1,1,0,0,-1,-1,1,-1, -1,1,0,0,1,-1,1,1, -1,-1,0,0,1,1,-1,1"
  -- l
  CreateBlockDefinition 6,"0,0, 1,0, 1,0, -2,1"
  CreateBlockTransform 6,"1,-1,0,0,-1,1,0,-2, 1,1,0,0,-1,-1,2,0, -1,1,0,0,1,-1,0,2, -1,-1,0,0,1,1,-2,0"
  -- j
  CreateBlockDefinition 7,"0,0, 1,0, 1,0, 0,1"
  CreateBlockTransform 7,"1,-1,0,0,-1,1,-2,0, 1,1,0,0,-1,-1,0,-2, -1,1,0,0,1,-1,2,0, -1,-1,0,0,1,1,0,2"
  
  repeat with loopRow = 1 to 20
    repeat with loopCol = 1 to 10
      put empty into sPlayArea[loopCol,loopRow]
    end repeat
  end repeat
  repeat with loopCol = 1 to 10
    put 0 into sPlayArea[loopCol,21]
  end repeat
  repeat with loopRow = 1 to 20
    put 0 into sPlayArea[0,loopRow]
    put 0 into sPlayArea[11,loopRow]
  end repeat
  put 1 into sGameLoopCounter
  put kNormalAnimation into sFallingDelay
  put 0 into sScore
  put empty into sKeyPress
  put false into sGameOver
  hide field "GameOver"
  put URL("file:" & TopScorePath()) into sScoreList
  put line 1 to 10 of sScoreList into field "TopScoreList"
  
  CreateNewTetromino
  unlock screen
  GameLoop
end StartGame

CreateBlockDefinition accepts a list of 4 pairs of numbers. For easier typing, these are entered as a list on a single line. All CreateBlockDefinition does is break this list into number pairs, each pair on a new line. Each pair indicates the position of the next block relative to the current block. So the top left corner of the tetromino is at 0,0. Then using the example of the square tetromino, 1,0 means draw the next block one column to the right, 0,1 means one row down and finally -1,0 is one column to the left.

command CreateBlockDefinition pType,pData
  local index,list
  replace space with empty in pData
  put 1 into index
  repeat for each item loopItem in pData
    put loopItem after list
    if index mod 2 = 0 then
      put cr after list
    else
      put comma after list
    end if
    add 1 to index
  end repeat
  put list into sBlockDefinition[pType]
end CreateBlockDefinition

CreateBlockTransform sets up sBlockTransform using a similar idea. Each block of the tetromino is moved relative to its current position. Each rotation is 90 degrees, so four transformations are needed to get it back to he original position. As with CreateBlockDefinition most of the work is about converting a single line of numbers into the correct pairs for each transformation. As hinted in the disclaimer near the beginning, while not much code is required, figuring out the numbers for each transformation was time consuming. And when it was finished I wished I had taken another approach.

command CreateBlockTransform pType,pData
  local index,list,transformCount,transformIndex
  replace space with empty in pData
  put 2 * (the number of lines in sBlockDefinition[pType]) into transformCount
  put 1 into index
  put 1 into transformIndex
  repeat for each item loopItem in pData
    put loopItem after list
    if index mod 2 = 0 then
      put cr after list
    else
      put comma after list
    end if
    if index mod transformCount = 0 then
      put list into sBlockTransform[pType,transformIndex]
      put empty into list
      add 1 to transformIndex
    end if
    add 1 to index
  end repeat
end CreateBlockTransform

The sPlayArea array represents the position of the blocks with column and row indices from 1 to 10 and 1 to 20. To translate each position into a coordinate on the screen, ConvertColRowToScreenXY does some maths. TopScorePath returns a path for the top scores file. In this case it is the same folder as the stack. In a standalone program, and especially on a mobile device this would need to be changed.

function ConvertColRowToScreenXY pCol,pRow
  return 340 + kBlockSize * (pCol - 1),32 + kBlockSize * (pRow - 1)
end ConvertColRowToScreenXY

function TopScorePath
  local buffer
  put the effective filename of this stack into buffer
  set itemDelimiter to slash
  delete the last item of buffer
  put slash & "livecode-tetris-scores.txt" after buffer
  return buffer
end TopScorePath

CreateNewTetromino creates the four LiveCode graphic objects, positioning each one according to the numbers in sBlockDefinition. Each tetromino has a different colour and the specific type of tetromino is randomly selected. The short id of the graphic and its column and row are put into a list in the sFallingBlocks variable. Storing the information for the falling blocks in a variable separate from sPlayArea (which only stores the blocks that have stopped falling) makes MoveBlocks easier to code.

A property named pvBlock is added to each graphic. This is done to allow the loop back in StartGame to only delete the graphics of the blocks, and nothing else (for example, the border of the play area which is also LiveCode graphic objects) on the card.

If the "s" tetromino is created it is positioned down the screen by one row. This is done because it's definition draws two of the blocks higher up the screen, which results in a negative y value for the screen coordinates. When creating a graphic object from code, LiveCode will adjust the loc property to remove negative values, even if you want the control to be created off screen. You can always set the loc property after the call to create graphic, but here it is just moved down the screen by one row before it is created.

command CreateNewTetromino
  local blockID,blockCol,blockRow
  put random(kMaxBlockType) into sBlockType
  put 1 into sBlockRotation
  put 5 into blockCol
  put 1 into blockRow
  if sBlockType = 3 then add 1 to blockRow
  put empty into sFallingBlocks
  reset templateGraphic
  set the style of the templateGraphic to "rectangle"
  set the rect of the templateGraphic to 1,1,kBlockSize,kBlockSize
  set the penColor of the templateGraphic to "black"
  set the filled of the templateGraphic to true
  repeat for each line loopLine in sBlockDefinition[sBlockType]
    add item 1 of loopLine to blockCol
    add item 2 of loopLine to blockRow
    set the loc of the templateGraphic to ConvertColRowToScreenXY(blockCol,blockRow)
    set the brushColor of the templateGraphic to item sBlockType of "#F8EB5F,#53B9ED,#60CC3A,#E93F1C,#E652E0,#F2AB54,#5882DF"
    create graphic
    put the short ID of the last graphic into blockID
    set the pvBlock of graphic ID blockID to true
    put blockID,blockCol,blockRow & cr after sFallingBlocks
  end repeat
end CreateNewTetromino

That is all the preliminary handlers out of the way. Almost half the code. Now time to start the game with GameLoop. The game loop runs at 30 fps and HandleKeyboard is called each time. While GameLoop is called 30 times per second, there is no need to update the position of the falling blocks that often. The sGameLoopCounter mod sFallingDelay expression means the blocks fall one row every second and then collisions checked for. If necessary, any filled rows are deleted and the score updated.

on GameLoop
  HandleKeyboard
  if (sGameLoopCounter mod sFallingDelay) = 0 then
    if StopFallingOnCollision() then
      RemoveFullRows
      CreateNewTetromino
    else
      UpdateFallingBlocks
    end if
  end if
  if sGameOver then
    GameOver
  else
    -- run game at 30 fps
    send "GameLoop" to me in 2 ticks
    add 1 to sGameLoopCounter
  end if
end GameLoop

HandleKeyboard acts on the sKeyPress variable and if necessary moves or rotates the falling blocks. When a space is pressed, sFallingDelay is set to 2. This speeds up the falling of the block, while the game loop is still at 30 fps, UpdateFallingBlocks is called every second time around the loop to make it fall faster.

command HandleKeyboard
  if sKeyPress is not empty then
    switch sKeyPress
      case "left"
        MoveBlocks -1
        break
      case "right"
        MoveBlocks 1
        break
      case "up"
        RotateBlocks
        break
      case "space"
        put kFastAnimation into sFallingDelay
        break
    end switch
    put empty into sKeyPress
  end if
end HandleKeyboard

MoveBlocks moves the blocks one column, either to the left or the right. A loop makes a local copy of sFallingBlocks, but with the new positions and this is then checked to see if there are any collisions. If all positions are free, DrawBlocksInNewPosition is called to update sFallingBlocks and the loc of each block.

command MoveBlocks pDelta
  local blockInfo,fallingBlocks
  repeat for each line loopBlockInfo in sFallingBlocks
    put loopBlockInfo into blockInfo
    add pDelta to item 2 of blockInfo
    put blockInfo & cr after fallingBlocks
  end repeat
  if CollisionFree(fallingBlocks) then
    DrawBlocksInNewPosition fallingBlocks
  end if
end MoveBlocks

RotateBlocks is similar to MoveBlocks but the position of each block is not adjusted by the same amount but instead by the contents of sBlockTransform which contains the necessary delta values for each block at each point of the rotation. Again, if all positions are free, DrawBlocksInNewPosition is called to update sFallingBlocks and the loc of each block.

command RotateBlocks
  local transIndex,fallingBlocks,delta,transformation
  put sBlockTransform[sBlockType,sBlockRotation] into transformation
  put 1 into transIndex
  repeat for each line loopBlockInfo in sFallingBlocks
    put line transIndex of transformation into delta
    put (item 1 of loopBlockInfo),(item 2 of loopBlockInfo + item 1 of delta),(item 3 of loopBlockInfo + item 2 of delta) & cr after fallingBlocks
    add 1 to transIndex
  end repeat
  if CollisionFree(fallingBlocks) then
    DrawBlocksInNewPosition fallingBlocks
    add 1 to sBlockRotation
    if sBlockRotation > 4 then put 1 into sBlockRotation
  end if
end RotateBlocks

DrawBlocksInNewPosition does what is says and updates the loc of the graphic objects.

command DrawBlocksInNewPosition pFallingBlocks
  lock screen
  put pFallingBlocks into sFallingBlocks
  repeat for each line loopBlockInfo in sFallingBlocks
    set the loc of the graphic ID (item 1 of loopBlockInfo) to ConvertColRowToScreenXY(item 2 of loopBlockInfo,item 3 of loopBlockInfo)
  end repeat
  unlock screen
end DrawBlocksInNewPosition

Checking for a collision in CollisionFree is simplified because only the stationary blocks are in the sPlayArea array. If sPlayArea also stored the falling blocks there would be extra checks to do.

function CollisionFree pFallingBlocks
  local continue
  put true into continue
  repeat for each line loopBlockInfo in pFallingBlocks
    if sPlayArea[item 2 of loopBlockInfo, item 3 of loopBlockInfo] is not empty then
      put false into continue
      exit repeat
    end if
  end repeat
  return continue
end CollisionFree

StopFallingOnCollision has it's own loop to check for any overlap (collision) when the blocks are moved down one row. If there would be an overlap, sPlayArea is filled at the positions of the now stationary blocks and sFallingBlocks is emptied, ready for the next tetromino. sCollisionRow is set to reduce the number of rows that RemoveFullRows needs to check next. If there is a collision at the top row the game is over.

function StopFallingOnCollision
  local collision,collisionRow,topRow
  repeat for each line loopBlockInfo in sFallingBlocks
    if sPlayArea[item 2 of loopBlockInfo, item 3 of loopBlockInfo + 1] is not empty then
      put true into collision
      exit repeat
    end if
  end repeat
  if collision then
    put 0 into sCollisionRow
    put 10 into topRow
    repeat for each line loopBlockInfo in sFallingBlocks
      put item 3 of loopBlockInfo into collisionRow
      put min(collisionRow,topRow) into topRow
      put max(collisionRow,sCollisionRow) into sCollisionRow
      put item 1 of loopBlockInfo into sPlayArea[item 2 of loopBlockInfo, collisionRow]
    end repeat
    put topRow = 1 into sGameOver
    put empty into sFallingBlocks
    put kNormalAnimation into sFallingDelay
  end if
  return collision
end StopFallingOnCollision

After a tetromino has stopped moving, RemoveFullRows is called. This is a long handler. The sCollisionRow variable set in StopFallingOnCollision means only 4 rows (the maximum height of a tetromino is 4 blocks) in sPlayArea need checking. Sure, sPlayArea is a small array and the performance (speed) of the game would be the same if the entire array was checked, but if for the cost of one line of code and a variable I can make a loop 5 times faster, why not make it faster? Thinking about these issues, and processing only the data that needs to be, can be the difference between a slick game and a sluggish one.

When rows are removed the score is updated.

command RemoveFullRows
  local checkRow,rowFull,rowEmpty,blockID,removeRowCount,emptyPlayArea
  put sCollisionRow into checkRow
  put 0 into removeRowCount
  put false into emptyPlayArea
  repeat until checkRow < (sCollisionRow - 3)
    put true into rowFull
    repeat with loopCol = 1 to 10
      if sPlayArea[loopCol, checkRow] is empty then
        put false into rowFull
        exit repeat
      end if
    end repeat
    if rowFull then
      put true into emptyPlayArea
      repeat with loopCol = 1 to 10
        delete graphic ID sPlayArea[loopCol, checkRow]
      end repeat
      wait 200 milliseconds with messages
      repeat with loopRow = checkRow down to 1 step -1
        put true into rowEmpty
        repeat with loopCol = 1 to 10
          put sPlayArea[loopCol, loopRow-1] into blockID
          put blockID into sPlayArea[loopCol, loopRow]
          if blockID is not empty then
            set the loc of the graphic ID blockID to ConvertColRowToScreenXY(loopCol,loopRow)
            put false into rowEmpty
            put false into emptyPlayArea
          end if
        end repeat
        if rowEmpty then exit repeat
      end repeat
      add 1 to removeRowCount
      wait 200 milliseconds with messages
    else
      subtract 1 from checkRow
    end if
  end repeat
  if removeRowCount > 0 then
    add item removeRowCount of "50,150,350,1000" to sScore
    if emptyPlayArea then add 2000 to sScore
    put sScore into field "Score"
  end if
end RemoveFullRows

If there isn't a collision in StopFallingOnCollision the falling blocks of the tetromino are moved down one row by the call to UpdateFallingBlocks.

command UpdateFallingBlocks
  local blockInfo,fallingBlocks
  repeat for each line loopBlockInfo in sFallingBlocks
    put loopBlockInfo into blockInfo
    add 1 to item 3 of blockInfo
    put blockInfo & cr after fallingBlocks
    end repeat
  DrawBlocksInNewPosition fallingBlocks
end UpdateFallingBlocks

The next two handlers receive the LiveCode messages related to the keys and puts them into the sKeyPress variable for use by HandleKeyboard.

on arrowKey pKey
  put pKey into sKeyPress
  pass arrowKey
end arrowKey

on keyDown pKey
  if pKey = space then put "space" into sKeyPress
  pass keyDown
end keyDown

Lastly, GameOver is called from the game loop if sGameOver is true. This updates the high score list and uses the LiveCode sort command and some handy string processing before storing the score.

command GameOver
  show field "GameOver"
  if sScore is not among the lines of sScoreList then
    put sScore & cr after sScoreList
    sort sScoreList numeric descending
    put line 1 to 10 of sScoreList into field "TopScoreList"
    put sScoreList into URL("file:" & TopScorePath())
  end if
end GameOver

Tetris on LiveCode. I hope you enjoy as much as I do, chasing an ever higher score. At the risk of repeating one central idea from my previous articles, how cool is it that with LiveCode a game like this can be coded in an afternoon?

Download the complete LiveCode Tetris stack made in LiveCode 6.1.2 from here: LiveCode Tetris.livecode.

Happy LiveCoding.

Tagged: 2D casual intermediate puzzle retro

Monday, February 10, 2014

0 Responses

Be the first to make a comment.


COMMENTS ARE CLOSED

 

Legal Stuff

All blog posts are copyright and cannot be re-used without permission. But all the code and scripts are dedicated to the public domain. Use such code and scripts in any way you want, but I am not responsible if they don't work for you.

Contact

Further comments or feedback? You can contact me by email at: