Quick viewing(Text Mode)

Network Tetris an Interactive Peer to Peer Approach

Network Tetris an Interactive Peer to Peer Approach

Michael Kotovsky Operating Systems Project Comp512, Spring 2008 Penn State Harrisburg

Network An interactive peer to peer approach

Table of Contents

Introduction & History of Tetris...... 3 Project Goals...... 4 Design Decisions ...... 5 Implementation ...... 6 An Event Driven Game...... 8 The flow of Logic ...... 8 Description of an Event ...... 10 Lifecycle of an Event...... 10 Rollbacks...... 12 Lag & Screen Synchronization ...... 13 The Kotovsky-Barber Lag Decay Heuristic ...... 14 Data Structures & Concepts...... 15 Random Numbers ...... 15 Sockets ...... 18 Event Factory...... 19 Event Queue...... 19 Event Manager...... 19 Event History ...... 20 Ack Queue ...... 20 Game Logic & Threading ...... 21 Closing Thoughts & Future Work ...... 23 References...... 24

Figures Tetrominoes...... 3 Screenshot of user interface...... 7 Game Flow...... 8 Event Lifecycle...... 4 Rollback Example...... 12 NRandom...... 16 Thread Tree...... 22

2

Abstract. Tetris has been a classic staple of video games, dating only a decade older than the era of Pong. This project adds the common twist of multiplayer play, the uncommon ability to actively interfere in what would otherwise be a parallel series of single player games and uniquely accomplishes this in a peer-to-peer environment across a TCP capable network. This multiplatform project emphasizes state consistency in an event driven environment with minimal message passing. Outlined below is a history of Tetris, project goals and the details of a Java implementation of Network Tetris.

Introduction & History of Tetris

Tetris was originally developed by Alexey Pazhitnov for the Electronica 60 while attending the Moscow Academy of Science in June, 19851, using as a basis for his game2. Rights to the game were sold outside of the USSR without prior approval from the game’s developer, creating early ports to the Apple II, and IBM PC before a contract for the game was official signed a couple of years later. The game’s popularity continues to spread internationally over the years like wildfire. After much legal debate, manages to get the rights to the game in 1989, forcing other competitors such as Atari out of the Tetris market – all the while, Pazhitnov receives nothing in the way of royalties, the profits going to the socialist Soviet government. After a few more years of lengthy legal debate, the rights to the game are finally settled, but Pazhitnov still has yet to see royalties for his creation until the disseverment of the USSR and the creation of , LLC in 19961, now an acquisition of Electronic Arts2. The game play for Tetris is relatively simple. A random sequence of Tetrominoes (a shape composed of four blocks, see Figure 1) appears at the top of a 20 tall by 10 long grid. Theses shapes slowly drift downward until they hit the bottom of the grid or another piece, where they set in place. The user has the ability to move these pieces left, right and down or to rotate the pieces 90 degrees. When an entire row is filled with these Tetrominoes, that line is cleared and everything above it is shifted downward

3

by one row. The game ends when pieces pile up to the top of the grid there is no room to spawn any new pieces. As the game progresses, pieces begin to drop at a faster rate, making survival ultimately impossible. Many versions add their own scoring calculations and other features to enhance this basic concept2. Since it’s inception over twenty years ago, Tetris has been ported to just about every platform available – consoles, handhelds, arcade machines and PC releases. It has seen special attention and, thanks to its grid like structure, has even been played using the lights of buildings at Brown University and MIT. Thanks to its simplistic nature, obfuscated code for the game has been written – that would take up less than half this page – to implement the most basic features of the game. A lot of theoretical work has gone into the study of this simple game as well, encouraging the development of advanced AI for not only this game in a traditional sense, but in a one (trivial), three and even N (arbitrary) dimensional space3. While versions of the game mutate the playing field (such as playing on a sphere mesh rather than a simple grid), the more popular adaptations to this classic have typically been the introduction of multiplayer elements. Putting boards side by side and competing for points is often a challenge enough; some variants add the ability for players to actively interfere with the normal game flow of their opponent, be it hindering the flow of their game or altering the state of the board4. More interestingly are the networked versions that run on a computer such as NeTris and TetriNET. These systems allow multiple players across the world to connect take actions that would change the state of their opponent’s board. However, these run on a Client Server model where centralized servers are responsible for arbitrating decisions regarding timing and the player’s game states5. Removing the Client/Server model from the game became the focus of this project.

Project Goals

The goal for this project was to create a two-player networked version of Tetris, allowing the players to actively clash with each other without the need for a central server to handle decisions. Removing the necessity of a server allows two players, anywhere in the world, to play without a single player having an advantage due to network latency or any other sort of bias. For example: A nearby client connecting to a server one state away is likely to have a significant advantage

4

over someone halfway around the world, simply because the closer connection is likely to have less latency between it and the server. This is only exacerbated if one of the clients is acting as a server, prioritizing their moves ahead of anything else that rolls down the network. In a strictly peer-to-peer model, any action taken would need to be agreed upon in some way by both clients and no action taken would be shown bias over another. It would be simple to somehow package game states and allow clients to arbitrate them as they were sent, but sending an entire board worth of data in a fast paced game such as this would suffer over lagged connections. In order to combat this, any messages shared should be as minimal as possible. This, however, falls under the restriction such that all actions taken in the game are fair with respect to whether the event was generated locally or externally, as described above. Given the minimalist nature of these messages, it is of utmost importance that each client maintains “near identical” copies of each other’s game states. To expand upon the term “near identical,” we consider – it is ultimately impossible to perfectly maintain an exact duplicate across a network without unnecessary agreements and handshakes. By “near identical” I impose that all changes to a game state will eventually be seen in the same order by both clients. Further, efforts are made such that any change to the game state will be shown on both clients’ screens at approximately the same time. This insures that clients will have the same knowledge at approximately the same time.

Design Decisions

Because of the necessity that messages between clients be seen in the same order, a TCP stream socket connection was a natural choice for communication. At the cost of additional overhead, this maintains that events sent via socket will be received in the same order, events are guaranteed to be received (it would be extremely detrimental if clients were loosing messages across the network) and any issue in the network connection (such as a disconnect) could quickly be discovered and dealt with. Due to the short amount of time it requires to play a game of Tetris, any socket related problems would signal a disconnect and the end of a game. Since the games are designed to be played in less than 5 minutes, no mechanism is currently in place to restore a game state on both clients; such a feature could be added at a later date.

5

As a game, the logical choice for a programming style was to embrace an event driven game. Any keyboard press by a player or a message received on the TCP stack signals the game to respond and handle that event as appropriate. Waiting on events will require a thread to sleep on the lack of input, so an environment that supports multithreading will be a necessity. To keep the game interesting to potential players, the development environment will also be required to support a graphic user interface along with multimedia support such as sound. For this steep list of requirements, it was decided that Java would be the easiest environment to develop in. While not necessarily a performance language, it has the promise of multiplatform support and offers significant ease in project development with its multitude of built in libraries and exhaustive documentation. Events, represented as objects, can be serialized and transported across object streams. Attaching these to a Java socket is a matter of ease, simplifying development and eliminating the need for an exchange protocol. Threads are implemented as simple objects and, thanks to synchronization elements built into Java objects, little extra effort is necessary to maintain mutual exclusion amongst threads. Swing interfaces are easy to develop and allow for robust event handling (such as the kind generated on keyboard input) and there is already built in support for the playing of multimedia (such as midi files). The use of Java, however, comes at a price. As was discovered later on, Java doesn’t handle context switching between many threads (close to 10 for this implementation) in a graceful manner, leading to a slower run time than I would have preferred. In the end it was a trade off between ease and elegance of design versus run time performance. While a fast paced game, Tetris is simplistic enough not to require any sort of 3d rendering or other demanding calculations, thus the performance hit implied by using Java was within acceptable bounds.

Implementation

The interface for Network Tetris can be seen below in figure 2. The majority of the interface is divided into two play areas, the left side being the user’s game board, the other is the opponent’s. At the top of the screen are various controls and menus. Each player’s score is listed just above their game board. The Game Menu contains various system related features such as connecting to other clients and disconnecting, quitting the application and beginning new games. The Music menu controls multimedia output and the Help menu provides information about the

6

application and game (such as how to play). Lastly are a trio of buttons, “Debris,” “Drill” and “Virus.” Once a player accumulates enough points (by clearing lines in the game) they can utilize these buttons to interfere with the other player’s game state, as indicated under “Help → How to play.”

Figure 2 – Screenshot of user interface.

Players must connect to another client before they can begin play. Due to the way the keyboard bindings and socket bindings are set up, two players must be on separate computers in order to play. An IP addresses or a host name (whose IP can be looked up) will suffice to connect to another client. In order to run the application, a client must have at least a compatible Java runtime environment (1.5+) and the ability to bind a server socket (listen) on port 10509.

7

An Event Driven Game

Figure 3 – Game Flow

The flow of Logic Main game flow (I.E. the game logic) is broken into one of five phases. See figure 3 above. i. Waiting for Connection ii. Waiting for Game Start iii. Initializing Game iv. Playing Game v. Emergency Pause The application begins in phase i. After initializing necessary objects (such as the GUI and sockets), the application waits for an incoming connection. Once another user makes a connection to the local client (or local client connects to another host), the game moves into phase ii. While no connection is established, the logic thread will go to sleep for a length of time, checking again when it wakes up. It should be noted that if at any point in phases ii through v a connection is lost (be it a local/external disconnect request or socket error), the game logic will revert back to phase i and wait for another connection attempt.

8

In phase ii, clients start processing events generated locally and read from the sockets. Clients also begin exchanging ping requests to establish an accurate measure of latency. This measure is used during game play and when game play begins to approximate screen synchronization between clients. During this phase, clients wait for a request for a new game to begin, much the same as they waited for a connection in phase i. The logic will continue to process events (ping requests and replies are handled during this process) while there are events to process. When the logic finds an event signaling a new game, the logic then moves to phase iii. In phase iii, clients are connected and a new game has been signaled. Clients use this phase to exchange parameters such as random seeds and initialize game state objects. Random numbers (used to generate seeds) are locally generated and exchanged via sockets as an event. This phase will continue to loop until either a disconnect is found (in which case it aborts to phase i) or a game parameter event is processed; the logic thread again sleeps for a short duration when there is no event to process. Once all parameters are collected, game boards (and their respective states) are reset, pseudorandom number generators are seeded and the logic moves to phase iv. Upon entering phase iv, the application is playing a game of Tetris. Input from the keyboard is enabled and keyboard input will trigger the creation of an event, sent to both the local and external clients. The logic will automatically generate messages to cause game pieces to drop (i.e., gravity, speeding up as necessary depending on game score). Events will continue to be processed and any game event that would alter a game state does so. If at any point the latency between clients becomes higher than some threshold δ, the game flow moves into phase v. Once a game event that signals the end of the game is processed, logic goes into stage ii to wait for the signal of a new game. If a client enters state v, then the latency between clients is deemed too high to continue play and the game is effectively paused. A warning message is issued, keyboard input is disabled and events continue to be processed to let old events a chance to catch up. Ping requests are issued to keep an active assessment of lag (since typically ping requests are packaged along with gravity requests in phase iv to save space). Once the latency falls below a second threshold β (where β < δ), keyboard input is re-enabled and the logic returns to phase iv where it left off. It is important to note two separate thresholds β and δ. These are set to different values so that upon exiting phase v, it’s not likely to immediately return to phase v if latency hovers around δ.

9

Description of an Event Events fall under two categories: System Events and Game Events. System events are events needed by the application but do not influence game flow (once in phase iv & v) directly. These events signal the start and end of the game, explicitly request a latency reply or exchange parameters. Most notable of system events is the Ack reply – an event generated when another event requests a reply. This is used to establish a measure of latency between clients. Game events, on the other hand, are exchanged during game play (phases iv &v) and directly alter the state of the game. These events are keyboard generated (player pushing left/right/etc to move their Tetris piece), system generated (such as gravity) or explicitly generated user interrupts (by clicking on the appropriate button – see “Interrupt Opponent” in figure 3 of the Interface). Regardless of the type of Event, they all share a few common features. Each event possesses two timestamps: a real timestamp and a logical timestamp. Real timestamps are generated from the local system time anytime an event is created or read from the socket. Real timestamps are only used on the machine that generated the timestamp and are used for latency determination. Logical time stamps are monotonically increasing integers generated with respect to the game state that they are to effect; since there’s more than one game state, there is more than one logical timestamp sequence. Sequentially generated timestamps are generated with a gap so that in the event that an outside source needs to interfere or insert a timestamp into an ordering, room is available. In addition to timestamps, each event is also given a set of flags: a flag is assigned to tell a given event whether it was locally or externally generated (read from a socket), whether special instructions are necessary for processing the event (if the event is to be discarded or given special priority, i.e., an interrupt) or if the event requests an Ack reply. Lastly, since each game event has the potential to alter a game state, they are assigned an initially empty field to access rollback instructions in order to undo their change to a game state, if necessary (see below).

Lifecycle of an Event Events, once generated by either the GUI or game logic, are sent to the Socket Manager, which is essentially the communication hub of the application. Any event (other than an Ack reply) that is received by a Socket Manager is mirrored and sent to the other client across the network. In this way, all knowledge is shared between clients with no bias (except as it relates to

10

latency calculations, which are done locally). Once an event has been sent to the Socket Manager (either locally or read from a socket), it is placed in an Event Queue (a priority queue) where it waits until the Event Manager can process it. See Figure 4 for a graphical representation.

Figure 4 – Event Lifecycle

Events wait in one of two queues, a local and external queue, to wait to be processed (as necessitated by the game logic in phases ii through v). When an event needs to be processed, it is taken from an Event Queue. A copy is made to an Event History to record the ordering in which events were processed. If an event had an Ack request, or was an Ack reply, it is also sent to an Ack Queue for use in lag calculation. If an event is flagged to be discarded, the Event Manager polls a new event from the Event Queues and repeats this process until either it can return an event (one not flagged to be discarded) or there are no more events to be processed in the Event Queues. The event (or lack thereof) is returned to the game logic as processed.

11

Rollbacks

Given the ability for users to actively interrupt each other, we run into the possibility where an event is generated too late. That is, its logical timestamp is less than that of an already processed event affecting the same game state. In order to combat this, any time an event alters the game state, instructions are issued on how to undo those changes caused by that event. If, during the course of event processing, the Event Manager tries to process an event with a logical time stamp lower than that of its last processed event (for that game state), it will look to the Event History. The Event History keeps a list of events that were processed in the order that they were actually processed, rather than the order they should have been. Rollback instructions are executed for each event in the history until the logical ordering of events is re-established; events that were rolled back are placed back into the Event Queue to be reprocessed. An example of this is detailed in figure 5. It is important to note that Event Queues are implemented as priority queues; the natural order for such queues is as follows: interrupts come before non-interrupts. Events with older logical time stamps come before events with older time stamps.

Figure 5a – Rollback example part 1

In figure 5a, events 2, 4 and 6 are waiting to be processed. For this example, the number of the event is also its logical time stamp.

Figure 5b – Rollback example part 2

A few event cycles later, events 2 and 4 have been processed and placed into the Event History. Events 6 and 8 are still waiting in the Event Queue to be processed

12

Figure 5c – Rollback example part 3

A few event cycles later, event has been processed. After that, event 3 enters the Event Queue. This is a problem, however, since we’ve already processed events 4 and 6, which should logically come after event 3. The rollback instructions for events 4 and 6 are executed and they are placed back into the Event Queue (with their rollback instructions and Ack request flag removed).

Figure 5d – Rollback example part 4

Now that events 4 and 6 have been effectively undone, we can continue processing events in the order in which they should have logically come, starting with event 3. With event rollbacks and rollback capability in the Event Manager, we achieve one of the goals of the project: maintaining that changes to game states will eventually be seen in the same order by both clients. If any event were to occur out of logical order, the Event Manager can undo actions to maintain that both clients see the same events in the same order, eventually.

Lag & Screen Synchronization

One of the primary goals in the project was to try to approximate synchronized screens. In order to do this, however, we need some measure of latency between the two clients. With that latency in mind, the Event Manager can delay the processing of local events by a measure of latency between the two clients, giving the external client (with what the local client assumes is) an equal opportunity to receive and process an event. In other words, any local event waits to be processed in an Event Queue until the real timestamp of the event plus the current latency is at least equal to the current time. The problem then becomes: How do we determine latency?

13

If you recall, each event has an Ack request flag associated with the event. The moment an event is created, it is given a local, real timestamp. The moment an event read from the sock it is also given a real time stamp; in addition, if that event was flagged as an Ack request, an Ack reply is immediately sent over the socket (with an identical logical time stamp to pair requests/and replies uniquely). With this, we have a real timestamp the moment an Ack request is made and when it is acknowledged. When an event with an Ack request flag is processed by the Event Manager, it is added to an Ack Queue. When an Ack reply is processed, its corresponding Ack request is removed from the Ack Queue and the difference between the timestamps is used to calculate a new estimate of latency.

The Kotovsky-Barber Lag Decay Heuristic Once an Ack request/reply pair is found in an Ack Queue (paired by logical timestamps), we let the value x be the following adaptation to Christian's algorithm: Real TS()(Reply− TS Request ) x = 2

If this value is larger than our current estimation of lag (lagn ), we use this value as our new estimation of lag. If this value is lower, we use this in an exponential function to further estimate what our latency is.

⎪⎧x if x≥ Lagn Lagn+1 = ⎨ where 01≤ α ≤ ⎩⎪ααLagnn+−()1 x if x < Lag This exponential decay of lag allows us to decrease our estimate of lag at some reasonable rate (as determined by α ). Sudden jumps in latency, however, will not significantly taint the estimation of lag. We assume worst case when a high value for latency occurs; if latency spiked high, it’s entirely likely to stay that high for some duration. While we favor scenarios of lower latency, we can’t use low spikes as an estimation like as Christian’s algorithm would. 1 Thanks goes out to Jarred Barber for his suggestion of letting α = . Using this value, in 2 the event of decreasing lag, our current estimation will only be half as valuable in two iterations as it will be now. In cases of a sudden, extreme spike, the algorithm will self correct within a relatively small amount of iterations (with this value of α ). For example, consider a normal

14

latency of n. If the network were to suddenly experience a series lag spikes (be it one or a more lengthy series) suggesting a latency of cn, where c is some constant multiple of n, then it would only take the algorithm on the order of log2 c request/reply pairs to normalize once true latency returned to n. In this way, we quickly forgive high lag spikes and ignore the durations of high latency intervals without allowing a single, low latency outlier to corrupt an estimation of lag. When a call is made to the Ack Queue to retrieve the current estimation of lag (used both to synchronize screens and move into an emergency lag state [phase v in game logic]), the following is used as an estimation: ⎛⎞CurrentTime− TS (Oldest Event in Ack Queue) GetLag→ Max⎜⎟ Lagn , ⎝⎠2 Christian’s algorithm is used as a worst case scenario for the oldest event in the Ack Queue. If latency becomes high, it is especially likely the oldest event in the Ack Queue could indicate a larger lag than what our estimation currently is. This isn’t used in the heuristic above, however, because it’s entirely possible that an Ack reply already has been read from a socket, but has yet to be processed.

Data Structures & Concepts

Random Numbers The sequence of pieces seen by each client is generated in a pseudorandom order. Due to the minimal nature of message passing, however, when a new piece is spawned, that piece is not submitted over the network in any way. As such, both clients must have access to the same sequence of pseudorandom numbers without any unnecessary message passing. Thankfully the java.util.Random class packaged in java uses strict requirements on its algorithms and guarantees absolute portability between versions and platforms6. We then must address the problem of rollback instructions. Any event that requires the use of a pseudorandom number would also need to be able to undo that request, in other words, perform a rollback on the pseudorandom number generator. To accomplish that task I created a new abstract data type based on random number generation. While it is possible to simply add a stack to a random number generator to record the previous pseudorandom numbers generated, there needs to be a limit imposed on that generator

15

before old numbers should be removed from the stack. I also needed to look at the requirements for the pseudorandom sequences required by Tetris. In no way does the game require a cryptographically secure sequence of numbers. In addition, since there are only 7 different pieces that can be spawned, there’s no need for an especially wide range of numbers in the sequence. In that regard, I use java.util.Random to fill byte arrays that can guarantee at least N rollbacks. Three buffers of bytes store the pseudorandom sequence. The arrays are of size N to insure, at minimum, N rollbacks from a given number in the sequence, so long as the number of requested rollbacks does not also exceed the number (total) of requested pseudorandom bytes. An index is kept for the current buffer (buffers are arranged in a circular pattern with regard to each other) and a current position within that buffer. Each buffer is given a write lock such that unnecessary writes (a break in the sequence) are not caused as a result of a rollback. Initially, the first two buffers are filled with pseudorandom values with the lock on the second turned on. As random values are requested, the next byte in the array is returned until the bytes left in the array are exhausted. The next call will cause the position to move to the first value in the next buffer. This will unlock the buffer moved to and, if the next buffer is unlocked, will fill that buffer with new pseudorandom values (in addition to locking it). A request to roll back will simply move to the previous position in the buffer (or the last position in the previous buffer if at the beginning of a buffer) and subsequent calls will return values as usual. An example is provided below in figure 6.

Figure 6a – NRandom

16

In figure 6a, requests for a random number would return the byte at the current position of the first buffer and move the position down by one. After N requests, the current position would advance to the first position in the second buffer. This would unlock the second buffer, fill the third buffer with new values and lock it, as shown in figure 6b.

Figure 6b – NRandom

If a rollback is requested, the current position would move back to the final position in the first buffer. A subsequent call to retrieve a random number would reorient the position to the first spot in the second buffer. However, this request would not fill the third buffer as its lock is still engaged. In order to re-write over a buffer we would have to roll back an additional N states (to the end of the third buffer). A subsequent request for a random number would cause the second buffer to be filled with a new sequence of numbers, invalidating the whole sequence overall. This, however, is acceptable since the data structure insures only N rollbacks and this required N + 1. This implementation was chosen for several reasons: Firstly, filling a byte array every N requests is less costly, in time required, than generating a new random number every request. This means a sequence of N/4 numbers must be generated every N requests versus a single number generated every request. Secondly, bytes (possessing values 0-255) show only a small bias when choosing pieces. It was calculated that of the 7 possible shapes that could be

17

generated, using a byte only showed a 2.7% bias against 2 of the 7 shapes; this was well within tolerable constraints as the game was being designed. It’s not as important that pieces be exactly evenly distributed as it is that pieces be generated in the exact same order (i.e., the exact same pseudorandom sequence). For this implementation, it was deemed that N=16 was more than sufficient for the infrequent number of potential random rollbacks that occur and would provide a relatively stable number for the purposes of generating sequences.

Sockets The Socket Manager, otherwise known as the communication manager, needs to be responsible for a total of three sockets: an inbound socket, outbound socket and server socket. When the application is initialized, a server socket binds to port 10509 and a new thread is created to listen to that socket. This thread blocks, waiting on a new connection attempt. When an attempt is made (and no client is currently connected), the connection attempt is granted and a new thread is spawned to listen on this inbound socket. If an outbound socket doesn’t already exist, an outbound socket connection attempt is made to the same address that requested the inbound socket connection. In this way, a handshake is established and each client has a pair of sockets, one for reading and another for writing. Currently, the game is only implemented to handle two players at once and simultaneous connection attempts (between two clients or three/more clients) may cause unexpected results. Much in the same way that a thread blocks on the server socket waiting for incoming connections, each inbound socket has a thread that is responsible for listening upon it. These threads block, waiting for input on the socket. Once an object is serialized and sent via the network, this thread picks up the inbound object, applies a real timestamp, replies to any Ack requests, adds the event to the appropriate Event Queue (one local and one external) and returns to blocking on the socket, waiting for more inbound traffic on the socket. When an event is generated, locally, it is sent to the Socket Manager. The Socket Manager mirrors the event, sending the object to the other client and it adds it to the appropriate Event Queue. In this way, all events pass through the Socket Manager. Since multiple threads need access to the ability to send events, this method is made thread save by java’s internal monitors.

18

Event Factory Any event created locally by the client is done via a factory pattern. The factory is given access to a logical sequence generator (an object responsible for issuing logical timestamps) and can be called upon to create any of the events that may need to be processed.

Event Queue The Event Queue is set up as a priority queue with synchronization elements built into it. Due to the need for multiple threads to have access to the Event Queues (threads that make use of the Socket Manager’s utility to send messages as well as the game logic when processing events), all operations on the priority queue must be thread-safe. A natural ordering for events in these queues is defined by: Interrupts (such as a signal to end the game) happen before non- interrupt events; Logical TS(n) happens before TS(n + 1). The java.util.concurrent.PriorityBlockingQueue is used for this data structure.

Event Manager The Event Manager, as described in the “Lifecycle of an Event,” is the main object responsible for processing an event. It maintains an Event History and Ack Queue internally. Notably, when processing an event, the Event Manager must first obtain a lock on both local and external Event Queues to insure no additional input mid-process disrupts the current flow of events. These locks are released each iteration while attempting to process an event, allowing new events from the Socket Manager to enter the queue while processing discarded messages (such as Ack Replies) between iterations before locks are re-acquired. External events are given priority over local events with respect to the order in which they are processed. The implementation of the Event Manager is not yet complete. At the moment, there is an extraordinarily unlikely possibility that a race condition can occur between time-slicing on threads – as such, it is possible that an event might arrive out of order and disrupt the flow of the game – potentially showing different states per client. While unlikely, this possibility offers a threat to the distributed game state coherence. By fully implementing a rollback scheme (where not only the local game states are saved in a history for rollback but the external ones are as well), this issue could be solved using the current toolset of objects designed for this project.

19

Event History The requirement to rollback actions applied means that a history must operate in FILO order, suggesting a stack based architecture. Saving events beyond a reasonable amount of time, however, wastes space. The function of the Event History is to record events as they happen for the purposes of a rollback. After a certain amount of time, however, it becomes infeasible that those events will require a rollback, so events with a real timestamp older than some constant ϕ can be discarded from the end of the data structure, in a FILO order. To facilitate these two access requirements efficiently, a doubly linked list was used. As this object is contained within the Event Manager and its functions called only by the main logic thread, no synchronization elements were needed for this class. At the moment, there has been little testing for sufficient values of ϕ . It is required that ϕ be larger than the constant δ (the threshold determining when a game goes into an emergency lag state), but for the moment a placeholder value of arbitrary size has proved sufficient for current testing scenarios. This value, however, could use additional testing under a stressed network.

Ack Queue The Ack Queue works as a modified priority queue to store events with their Ack request flag turned on. A new natural ordering is imposed on the events such that an older, real timestamp comes before a newer timestamp. This was chosen for two reasons. Firstly, any call to retrieve an estimation of lag from the Ack Queue will need access to the oldest event in the queue. Secondly, the next Ack reply to be received is most likely to be the oldest event in the queue. An interesting problem arose partway into the development of the Ack Queue. Because all local events with an Ack request are subject to wait until the client thinks the other system has had a chance to process the event, combined with the fact that external events are processed before local ones, it is entirely possible that an Ack reply is processed before the matching Ack request is processed. To counter this scenario, a map data structure was added to the Ack Queue. We allow the Ack reply to be processed by the Ack Queue before the request, but instead of removing an element that doesn’t exist from the queue, we add the reply to the map (using its logical time stamp as a key). Whenever a request is sent to the Ack Queue, we first check the

20

map to see if its reply has arrived prematurely. If so, we don’t add the request to the queue but instead remove the reply from the map and proceed with estimating lag as described in “Lag & Screen Synchronization.” Even if a reply is processed before the request, the request is always guaranteed to have a real time stamp less than (or in an extreme case, equal to) the reply: The request is generated with a local timestamp, then sent across the network. Its reply is then later read from the socket and only then given a timestamp based on the system time. It’s in the latency determination that Java showed its weakness. Even for two clients directly connected via switch, the latency calculated was typically between 70 and 200 MS. This can be attributed to Java’s inability to efficiently context switch between threads. Oddly enough, testing showed little difference when working on high performance computers and older models – the limitation seemed to be built into the software rather than the implementing hardware. To review, a time stamp is given the moment an event is created. An event is almost always sent to the Socket Manager for transport immediately after it is created. A thread is dedicated to reading events from a socket, using blocking synchronization built into the Java framework. This thread is responsible for listening on a socket, timestamping, replying to Ack requests and forwarding messages to an Event Queue before blocking on the socket again. The only reason for such a significant delay goes not to improper prioritization of timestamping, but rather the delay involved in waking up the thread blocking on the socket on both the requester and replier’s end.

Game Logic & Threading

As the entry point for the application, the logic is responsible for creating all the event related objects and the threads that maintain them. Graphic summary of these threads can be found below in figure 7. The main logic spawns three distinct classes of threads: A thread maintaining incoming socket connections, various logic timers and a thread containing a GUI constructor. It is necessary to place the GUI in a separate thread so that interaction with the GUI doesn’t slow down the game logic, nor does the logic delay response to the GUI. The socket thread, upon incoming connection, spawns a new thread to listen on that socket. The GUI, in addition to swing related threads, makes use of its own timers for screen updates and other tasks, putting the thread count for the application at just over 10.

21

Figure 7 – Thread Tree

The decisions made for interrupting your opponent’s play were made to function simply and non-obstructively. The original intent for at least one of the interference options was to add content to the top of your opponent’s board, but it was determined mid-development that this could lead to more synchronization issues and it might be simpler to limit options to removing parts of the game state as opposed to adding it. Right now the options are elementary demonstrations: Removing a random column, removing random bits from the middle of the board or pieces from the top. In all cases, a custom graphic is displayed onto the screen to cover up any graphic “hiccups” that may occur as a result of requiring a rollback. This also adds an element of confusion, especially since a user can anticipate some unknown change during the time (fraction of a second) the splash remains on the screen. The game logic, in its current state, works according to the project goals, with a few assumptions made during development. Without adequate testing in a lagged or sufficiently low- end (in terms of hardware) environment, the current values for constants are purely speculatory. In addition, game play elements, such as the derivation of scores and value of the interrupt abilities need further crash testing to achieve a sufficient balance in terms of their value to the game. It has also been brought up that certain features of the game (such as the ability to start a new game mid-game) require some sort of restriction or negative-compensation. Some of the assumptions made regarding constants, however, do not necessarily prove sufficient under

22

stressed conditions. A clear example of this was discovered not long ago when attempting to run the application on a CPU taxed machine. The game logic normally allowed a few seconds for the client to initialize a thread in order to share variables across multiple threads. If it took longer than a few seconds, an error was assumed and the program terminated. Simply raising that constant was enough to compensate, but it demonstrated that current values of assumed constants require additional testing.

Closing Thoughts & Future Work

Overall, the project managed not only to succeed at the goals of creating a P2P networked version of Tetris (with active interference), but proved amusing to the pool of testers who had the opportunity to try out the software. The clients are able to connect and are able to maintain synchronous game states despite active interference and no central arbitration in an environment of minimal message passing. There do exist two issues that need to be addressed before any additional features (i.e. fluff) is added to the game. Currently it’s possible (however extremely unlikely) that states can be processed out of order due to an obscure race condition during thread time slicing. This can be fixed with the current tools that are available – it just requires another Event History object to be saved along with more rigid enforcement on state rollbacks. Secondly is an issue of policy in seed exchange. Some mechanism, essentially the implementation of some handshake event, would need to be implemented to assure that both clients are using identical seeds. Currently, it’s possible for both players to simultaneously start a game and end up with different seeds for their pseudorandom generators (and thus possess completely different game states). Beyond these issues are a few matters of crash testing game constants, adding features such as sound (in addition to the music included already) and more robust methods of interference. It should be noted that the game currently offers the same sequence of pieces to both players – this is intended. While it may be true that one player can gain advantage by observing the other player’s sequence of pieces, they lose time and thus points; additionally, this advantage is soon lost as the drift between rates of play makes memorization of pieces generated all but impossible. As a final note when playing Network Tetris: Enjoy.

23

References

1 Tetris: a history. May 2008. http://www.atarihq.com/tsr/special/tetrishist.html

2 Tetris. Wikipedia. May 2008. http://en.wikipedia.org/wiki/Tetris

3 Tetris AI: computer plays Tetris. Fahey, Colin. June 4. 2007 http://colinfahey.com/tetris/tetris.html

4 List of Tetris variants. Wikipedia. May 2008. http://en.wikipedia.org/wiki/List_of_Tetris_variants

5 TetriNET and Blocktrix Servers. May 2008. http://en.tetrinet.no/

6 Random(Java 2 Platform SE v14.2). JavaDocs. May 2008. http://java.sun.com/j2se/1.4.2/docs/api/java/util/Random.html

Images, Code and this document © 2008 Michael Kotovsky Midi files used in the game were downloaded from http://www.aganazzar.com/midi.html http://www.vgmusic.com/new-files/

TETRIS NAME WAS USED WITHOUT PERMISSION. THIS PROJECT, APPLICATION AND CODE ARE USED FOR EDUCATIONAL PURPOSES ONLY. UNDER NO CIRCUMSTANCES ARE THEY PERMITTED TO BE SOLD OR DISTRIBUTED OUTSIDE AN ACADEMIC ENVIRONMENT OR IN A COMMERCIAL FASHION.

THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

24