Interactive Event Loops

This page shows a full game of Tetris written by Fiendish that runs as a single mobprog.

It uses a background event loop (see the end where it says "makewait(function()...") that, while active, wakes up approximately once every second to check if the player has issued any new commands. (It actually does whatever work it needs to do and then sleeps for about a second. This is technically different than waking up once per second, but about the same in practice for small quantities of work.)

Background event loops can be useful because they allow for continuous processing even without any player interaction.

https://en.wikipedia.org/wiki/Game_programming has this to say on the subject of event loops:

Most traditional software programs respond to user input and do nothing without it. 
For example, a word processor formats words and text as a user types. If the user 
doesn't type anything, the word processor does nothing. Some functions may take a 
long time to complete, but all are initiated by a user telling the program to do 
something.

Games, on the other hand, must continue to operate regardless of a user's input. 
The game loop allows this. A highly simplified game loop, in pseudocode, might 
look something like this :

while( user doesn't exit )
  check for user input
  run AI
  move enemies
  resolve collisions
  draw graphics
  play sounds
end while


Example code

The player interacts with the loop via proxy objects that are created/destroyed when the player uses the available command keywords.

The proxy command objects used by this example program are as follows:

test-30           1  Player input - LEFT                       Trash        
test-31           1  Player input - RIGHT                      Trash        
test-32           1  Player input - ROTATE                     Trash        
test-33           1  Game status - ACTIVE                      Trash        
test-34           1  Player input - DROP                       Trash

An example proxy command definition is:

Input Left
T Key            Short Desc           Type    Usage Trigger
- -------------- -------------------- ------- ----- ------------------------------
R test-21        Tetris Room          Command    11 left 
Code:
  1. if (carries(self,"test-33")) then
  2.   purgeobj("all", LP_CARRIEDONLY+LP_SEEALL)
  3.   oload("test-33")
  4.   oload("test-30")
  5. end

The Tetris game code mobprog is:

if progactive(self.gid,ch.gid) then
   cancelthreads(self.gid,ch.gid)
end

local LEFT_COMMAND_OBJECT = "test-30"
local RIGHT_COMMAND_OBJECT = "test-31"
local ROTATE_COMMAND_OBJECT = "test-32"
local DROP_COMMAND_OBJECT = "test-34"
local GAME_ACTIVE_COMMAND_OBJECT = "test-33"
local SURRENDER_COMMAND = "surrender"

--- Each tetromino is constructed by an arrangement of several "[]" atoms.
--- The pattern for each piece shape is designated by indicating which Y,X coordinates
--- of a 4x4 grid need to be marked in order to draw that shape. Because "[]" is
--- 2 characters, the X coordinate increments by 2 while the Y coordinate increments
--- by 1, making this not a display-agnostic representation.
--- Each piece rotation is specified in order. Thus the rotate_piece function just
--- steps through the different layouts for that piece.
local piece_set = {
  {
    {{0,0},{0,2},{1,0},{1,2}} --- O rotation 1
  },
  { 
    {{0,0},{0,2},{0,4},{0,6}}, --- I rotation 1
    {{0,2},{1,2},{2,2},{3,2}} --- I rotation 2
  },
  {
    {{0,0},{0,2},{1,0},{2,0}}, --- J rotation 1
    {{0,-2},{0,0},{0,2},{1,2}}, --- J rotation 2
    {{0,2},{1,2},{2,0},{2,2}}, --- J rotation 3
    {{0,-2},{1,-2},{1,0},{1,2}} --- J rotation 4
  },
  {
    {{0,0},{0,2},{1,2},{2,2}}, --- L rotation 1
    {{0,-2},{0,0},{0,2},{1,-2}}, --- L rotation 2
    {{0,0},{1,0},{2,0},{2,2}}, --- L rotation 3
    {{0,2},{1,-2},{1,0},{1,2}} --- L rotation 4
  },
  {
    {{0,0},{0,2},{0,4},{1,2}}, --- T rotation 1
    {{0,2},{1,0},{1,2},{2,2}}, --- T rotation 2
    {{0,2},{1,0},{1,2},{1,4}}, --- T rotation 3
    {{0,2},{1,2},{1,4},{2,2}} --- T rotation 4
  },
  {
    {{0,0},{0,2},{1,2},{1,4}}, --- Z rotation 1
    {{0,2},{1,0},{1,2},{2,0}} --- Z rotation 2
  },
  {
    {{0,0},{0,2},{1,-2},{1,0}}, --- S rotation 1
    {{0,0},{1,0},{1,2},{2,2}} --- S rotation 2
  }
}

local game_board = {}
local displayed_board = {}
local piece_index, rotation_index
local current_piece
local game_over = false
local HEIGHT = 20
local WIDTH = 20 --- must be a multiple of 2, because the "[]" atom has 2 characters
local x_loc,y_loc
local score = 0
local total_lines = 0

local function put_piece_on_board(board)
  for a=1,4 do
    board[current_piece[a][1]][current_piece[a][2]] = "\["
    board[current_piece[a][1]][current_piece[a][2]+1] = "\]"
  end
end

local function blit_game_board()
  for j=1,HEIGHT do
    for i=1,WIDTH do
      displayed_board[j][i] = game_board[j][i]
    end
  end
  put_piece_on_board(displayed_board)
end

--- The game board gets drawn between fake MAP tags
--- so that an automap capture client plugin produces 
--- image persistence instead of scrolling past boards away.
local function draw()
  blit_game_board()
  local output_block = "\<MAPSTART\>\n\n"
  --- table.concat({"a","b","c","d","e"}) is more efficient than "a".."b".."c".."d".."e",
  --- but I use the latter form here for novice clarity.
  output_block = output_block.."Score: "..score.." - Lines: "..total_lines.."\n\n"
  output_block = output_block.."+--------------------+\n"
  for j=1,HEIGHT do
    output_block = output_block.."|"..table.concat(displayed_board[j]).."|\n"
  end
  output_block = output_block.."+--------------------+\n\n"
  output_block = output_block.."\<MAPEND\>\n"
  send(ch, output_block)
end

local function check_for_game_over()
  for a=1,4 do
    if game_board[current_piece[a][1]][current_piece[a][2]] ~= " " then
      return true
    end
  end
end


--- Generate a random piece and place it at the top of the
--- game board. If any atom of the new piece would be placed in 
--- an already occupied space, then the game is over.
local function new_piece()
  x_loc = 7
  y_loc = 1
  piece_index = math.random(7)
  current_piece = piece_set[piece_index][1]
  rotation_index = 1
  current_piece = {
    {current_piece[1][1]+y_loc,current_piece[1][2]+x_loc},
    {current_piece[2][1]+y_loc,current_piece[2][2]+x_loc},
    {current_piece[3][1]+y_loc,current_piece[3][2]+x_loc},
    {current_piece[4][1]+y_loc,current_piece[4][2]+x_loc},
  }
  game_over = check_for_game_over()
end

--- Moves the piece down one row.
local function lower_piece()
  for a=1,4 do
    if current_piece[a][1]+1 > HEIGHT or game_board[current_piece[a][1]+1][current_piece[a][2]] ~= " " then 
      return false
    end
  end

  y_loc = y_loc+1
  for a=1,4 do
    current_piece[a][1] = current_piece[a][1]+1
  end
  return true
end

--- Blank out the current piece, choose the next sequential rotation 
--- layout for the piece, and then use that layout instead. But only 
--- if the new layout would not put part of the piece off the edge of
--- the game board or overlap with an already dropped piece.
local function rotate_piece()
  local temp_rotation
  if rotation_index == #piece_set[piece_index] then
    temp_rotation = 1
  else 
    temp_rotation = rotation_index+1
  end

  for a=1,4 do
    if game_board[piece_set[piece_index][temp_rotation][a][1]+y_loc][piece_set[piece_index][temp_rotation][a][2]+x_loc] ~= " " then
      return
    end
  end

  rotation_index = temp_rotation
  current_piece = piece_set[piece_index][rotation_index]
  current_piece = {
    {current_piece[1][1]+y_loc,current_piece[1][2]+x_loc},
    {current_piece[2][1]+y_loc,current_piece[2][2]+x_loc},
    {current_piece[3][1]+y_loc,current_piece[3][2]+x_loc},
    {current_piece[4][1]+y_loc,current_piece[4][2]+x_loc},
  }
end

--- Moving pieces left and right by one atom if it won't make
--- the piece go off the edge of the screen or overlap existing
--- structure.
local function slide_piece(direction)
  local slidestep = 2 --- slide right
  if direction == LEFT_COMMAND_OBJECT then
    slidestep = -2 --- slide left
  end

  local new_x
  for a=1,4 do
    new_x = current_piece[a][2]+slidestep
    if new_x >= WIDTH or new_x <= 0 or game_board[current_piece[a][1]][new_x] ~= " " then 
      return -- can't slide in that direction
    end
  end

  x_loc = x_loc+slidestep
  for a=1,4 do
    current_piece[a][2] = current_piece[a][2]+slidestep
  end
end


--- We don't have a way to directly interact with the game loop, so
--- player commands create special objects that we then check for.
local function check_for_input()
  if carries(self,LEFT_COMMAND_OBJECT) then
    purgeobj(LEFT_COMMAND_OBJECT,LP_CARRIEDONLY+LP_SEEALL)
    return LEFT_COMMAND_OBJECT
  elseif carries(self,RIGHT_COMMAND_OBJECT) then
    purgeobj(RIGHT_COMMAND_OBJECT,LP_CARRIEDONLY+LP_SEEALL)
    return RIGHT_COMMAND_OBJECT
  elseif carries(self,ROTATE_COMMAND_OBJECT) then
    purgeobj(ROTATE_COMMAND_OBJECT,LP_CARRIEDONLY+LP_SEEALL)
    return ROTATE_COMMAND_OBJECT
  elseif carries(self,DROP_COMMAND_OBJECT) then
    purgeobj(DROP_COMMAND_OBJECT,LP_CARRIEDONLY+LP_SEEALL)
    return DROP_COMMAND_OBJECT 
  else
    return nil
  end
end

--- For each row of the board, check the entire width
--- of the row to see if the row is completed.
--- Completing 1 row gives 3 points. Completing 2 rows
--- gives 9 points. Completing 3 rows gives 27 points.
--- Completing 4 rows gives 81 points.
local function check_for_full_lines()
  local temp_score = 1
  for j=2,HEIGHT do
    local line_complete = true
    for i=1,WIDTH do
      if game_board[j][i] == " " then
        line_complete = false
        break
      end
    end

    if line_complete then
      total_lines = total_lines + 1
      temp_score = temp_score * 3 --- Getting multiple lines at once multiplies your score

      --- Remove the line and put a new blank one at the top.
      --- "Traditional versions of Tetris move the stacks of blocks down by a 
      --- distance exactly equal to the height of the cleared rows below them."
      --- https://en.wikipedia.org/wiki/Tetris#Gravity
      table.remove(game_board,j)
      table.insert(game_board,1,{})
      for i=1,WIDTH do
        game_board[1][i] = " "
      end
    end
  end
  if (temp_score > 1) then 
    score = score + temp_score 
  end
end

--- Act on player commands
local function board_update(command)
  if command == LEFT_COMMAND_OBJECT or command == RIGHT_COMMAND_OBJECT then
    slide_piece(command)
  elseif command == DROP_COMMAND_OBJECT then
    while(lower_piece()) do end
  elseif command == ROTATE_COMMAND_OBJECT then
    rotate_piece()
  end
  if not lower_piece() then
    put_piece_on_board(game_board)
    check_for_full_lines()
    new_piece() 
  end
end

--- Initialize the board.
local function init_board(board)
  for j=1,HEIGHT do         --- for each row
    board[j] = {}      --- make a new row
    for i=1,WIDTH do        --- build the row
      board[j][i] = " "
    end
  end
end

init_board(game_board)
init_board(displayed_board)

--- This is the main game loop.
--- Every second that the game mob holds the activation token,
--- check for player input, do something with that input, update the game
--- board, then display the board. Player can exit the game by issuing a
--- command (surrender) that destroys the activation token.
makewait(function()
  new_piece()
  local command
  while carries(self,GAME_ACTIVE_COMMAND_OBJECT) and not game_over do
    board_update(check_for_input())
    draw()
    fixedpause(1) --- Can't make the game go faster, because you can't pause for less than 1 second at a time.
  end
  purgeobj("all",LP_CARRIEDONLY+LP_SEEALL)
  send(ch,"Game Over!")
  force(ch,SURRENDER_COMMAND)
end
)