|
CS 4513 Project 4Dragonfly Wings - Extending the Dragonfly Game Engine with NetworkingDue date: Friday, May 2nd, 11:59pm |
You will extend the Dragonfly game engine with network support and create a two player, 2d shoot-em up game using your new Dragonfly.
Dragonfly is a text-based game engine, primarily designed to teach about game engine development. While it is a full-featured game engine for stand-alone computer game development, it does not provide any support for networking. In this project, you will remedy that by designing and implementing network support ("wings") for Dragonfly. Once networking is implemented and integrated into the engine, you will extend a single-player game using a traditional client-server game architecture to become a fully-distributed, two player, networked game.
If you have not already, you should work through Dragonfly tutorial available online through the Dragonfly Web page. Doing so will help you setup your development environment, as well as provide necessary background on game programming with Dragonfly. Furthermore, the tutorial game, Saucer Shoot, will serve as the basis for the two player modification you will make called Saucer Shoot 2.
You will create two classes in support
of Dragonfly networking: 1) a network
manager and 2) a network event. While these classes will not
technically be part of the Dragonfly engine
(the engine is an immutable library, libdragonfly.a
,
unless you have implemented the engine from scratch already), the new
network classes can easily be accessed through an additional library
(e.g., libnetwork.a
) you make and, hence, will behave as
if they are part of the engine.
The Network Manager handles the internals of a game's network connection. As such, both a game client and a game server have a Network Manager. Like other Dragonfly managers, the NetworkManager inherits from the Manager base class and is a singleton. Its main private attribute is a connected socket. Since the requirements for this project are only for a 2-player game, only 1 socket is required. The class header file is provided below:
// // NetworkManager.h // // Manage network connections to/from engine. // class NetworkManager : public Manager { private: NetworkManager(); // Private since a singleton. NetworkManager(NetworkManager const&); // Don't allow copy. void operator=(NetworkManager const&); // Don't allow assignment. int sock; // Connected network socket. public: // Get the one and only instance of the NetworkManager. static NetworkManager &getInstance(); // Start up NetworkManager. int startUp(); // Shut down NetworkManager. void shutDown(); // Accept only network events. // Returns false for other engine events. bool isValid(string event_type); // Block, waiting to accept network connection. int accept(string port = DRAGONFLY_PORT); // Make network connection. // Return 0 if success, else -1. int connect(string host, string port = DRAGONFLY_PORT); // Close network connection. // Return 0 if success, else -1. int close(); // Send buffer to connected network. // Return 0 if success, else -1. int send(void *buffer, int bytes); // Receive from connected network (no more than bytes). // Return number of bytes received, else -1 if error. int receive(void *buffer, int bytes); // Check if network data. // Return amount of data (0 if no data), -1 if not connected or error. int isData(); // Return true if network connected, else false. bool isConnected(); // Return socket. int getSocket(); };
Upon startup()
, the NetworkManager initializes the
socket to -1 (indicating the NetworkManager is not yet connected).
Upon shutdown()
, if the socket is connected (greater than
0), it is closed via the system close()
call. The
isConnected()
method checks if the socket is greater than
0, returning true
of so, else
returning false
.
The accept()
method undertakes setting up and then
waiting for a TCP/IP socket connection from a client, somewhere else
on the Internet. Typical of most Internet servers, the NetworkManager
listens on a ``well-known'' port (for most network games, this is
defined by the game developers) that the client uses when connecting.
It also assumes an external naming service provides the IP address
and/or hostname where the server (game host) is running. Inside
accept()
, the following steps are done:
socket()
.
bind()
.
listen()
.
accept()
.
The complimentary method, connect()
, is invoked by the
client to connect to the server. As such, it takes in the server's
hostname and port as string
s. When
invoked, connect()
does the following steps:
getaddrinfo()
.
socket()
.
connect()
.
The method isConnected()
can be used by either the
client or the server to tell if the NetworkManager has a connected
socket. It returns true
if the socket
(sock
) is greater than 0. The
method getSocket()
actually returns the socket.
The method send()
sends data through the connected socket
(using the send()
system call).
The method receive()
receives data from the connected socket,
using the recv()
system call). Receive is non-blocking, so if
their is no data pending, the method does not block but returns. The
receive()
method has a "peek" option to read the data but
leave the data in the socket.
The network event is a "message" that is communicated by the NetworkManager to all Objects that have registered interest in a network event. Implementation of the EventNetwork class is fairly straightforward as the network event class is mostly a container. The proposed design, shown below, does not include the actual network data (although it could) since that is assumed to be pulled from the NetworkManager by any interested game Objects.
// // A "network" event, generated when a network packet arrives. // #ifndef __EVENT_NETWORK_H__ #define __EVENT_NETWORK_H__ #include "Event.h" #define NETWORK_EVENT "__network__" class EventNetwork : public Event { private: int bytes; // number of bytes available public: // Default constructor. EventNetwork(); // Create object with initial bytes. EventNetwork(int initial_bytes); // Set number of bytes available. void setBytes(int new_bytes); // Get number of bytes available. int getBytes(); }; #endif // __EVENT_NETWORK_H__
If the NetworkManager was actually implemented in the engine, it
would be providing notification of all network events - i.e., when
network data arrived, it would generate an EventNetwork for all
Objects that had registered. However, for this project, since the
NetworkManager is implemented in game code, there needs to be a game
object running on both the client and host that generates network
events. This can be most easily done by a "sentry" object (call it
Sentry) that is inherited from Object and registers for step events.
Every step, Sentry polls the network manager and when network data has
arrived since the last step, it generates network events by calling
the NetworkManager's onEvent()
method. Since the
Host/Client had registered interest in network events, it is then
notified network data has arrived.
Saucer Shoot 2, the two player, networked version of Saucer Shoot, can, and should, make extensive use of the existing Saucer Shoot game taking advantage of game sprites and game objects provided. The base requirement must include the core gameplay (i.e., ships shooting saucers with starfield and points). Thus, the GameStart screen and the GameOver animation are not required. Nukes are also optional. The game can start right into the gameplay and end right when a Hero is destroyed. Note, including these optional elements can count towards the Miscellaneous points in the grading section.
For functionality, what Saucer Shoot 2 requires is for two players to play Saucer Shoot simultaneously from different, independent computers (both computers connected to the Internet). You can use some creativity in providing new gameplay (e.g., competitive or cooperative), with a suggested change to have both players on the same side shooting at the same Saucers, but competing for points. A possible screenshot showing competitive Heroes on the same side is shown below.
The actual design and implementation of Saucer Shoot 2 is up to you. There are many decisions that can be made in implementing an architecture for a multiplayer game such as Saucer Shoot 2, including: 1) what Objects are synchronized and how often, 2) how player actions on the client are transmitted to the server, and 3) how inconsistencies between client and server game states are resolved. Note, for the game architecture, one of the players can interact on the server (effectively, this player's computer is hosting the game), where the other player is the client that connects.
A significant task in synchronizing states in a network game (and in other distributed systems) is transferring data that needs to be synchronized between nodes. For a network game, and many other object-oriented systems, this means synchronizing the attributes of objects across computer systems. This is often done through serializing (also known as marshalling), where an object's state is translated into a format that can be transmitted across a network connection and reconstructed on another computer. Dragonfly provides several built-in methods for the Object class to make this easier, shown below:
// // Object class methods to support serialization // // Serialize Object attributes to single string. // e.g., "id:110,is_active:true, ... // Only modified attributes are serialized (unless all is true). // Clear modified[] array. virtual string serialize(bool all = false); // Deserialize string to become Object attributes. // Return 0 if no errors, else -1. virtual int deserialize(string s); // Return true if attribute modified since last serialize. bool isModified(enum ObjectAttribute attribute);
The method serialize()
produces a string
of Object attributes in key:value pairs separated by commas. For
example, the attribute and value for the Object id is represented as
"id:110,". By default, the serialization string returned contains the
attributes that have been modified since the last call
to serialize()
, unless invoked with the
boolean all
as true
.
The counterpart method, deserialize()
, takes in
a string
produced by serialize()
(presumably, on a separate computer) and parses it into the resulting
key:value pairs, setting all Object attributes as appropriate.
The method isModified()
queries individual methods to
see if they have been modified or not
(e.g., isModified(ID)
), returning true
if
the Object id has changed since the last call
to seralize()
. When an Object is first created, all
attributes indicate as having been modified.
Any derived classes (e.g., game programmer objects, such as
Saucers) need to implement their own versions
of serialize()
and deserialize()
in order
to serialize and deserialize any game-specific data. They can (and
should) call the parent class (de)serialize()
. The
utility functions below, provided
by Dragonfly, may be useful when making a
derived Object in a network game.
// Convert integer to string, returning string. string toString(int i); // Convert float to string, returning string. string toString(float f); // Convert character to string, returning string. string toString(char c); // Convert boolean to string, returning string. string toString(bool b); // Match key:value pair in string in str, returning value. // If str is empty, use previously parsed string str. // Return empty string if no match. string match(string str, string find);
The toString()
functions are self-explanatory.
The match()
method looks for exactly one key in an input
string
, returning the associated key paired with it as a
string. The first call to match()
should be made with
the serialized string, whereupon match()
parses the
string and stores the key:value pairs internally
(as static
variables). Subsequent calls
to match()
should be invoked with an empty string as the
first argument, matching each of the attributes as a key until done
parsing. The functions atoi()
and atof()
can be used to convert the resulting strings to numbers, if
needed.
There are many choices as to how and when to serialize, send, receive and then deserialize game objects. However, some suggestions that are relevant for many network games, and are certainly relevant for this project, are as follows:
Only synchronize "important" Objects and associated events. For example, a player's Hero ship being destroyed is an important (perhaps, the most important) event and should be serialized across computers. Stars, on the other hand, only provide decoration and, as such, do not need to be synchronized at all. Some guidelines are shown in the table below:
Synchronize | Don't Synchronize |
Saucer creation/destruction | Stars |
Bullet creation/destruction | Object movement that velocity handles |
Hero creation/destruction | Explosions |
Points increase | |
Object position changes |
Some of the guidelines above are subtle, such as the creation of
Explosions. These could be handled by creating an Explosion on the
host, then synchronizing with the client. However, it can also be
handled by synchronizing the destruction of the Saucer (which must be
done, anyway) where the destructor in each Saucer
(e.g., ~Saucer()
) creates an Explosion object. Both
client and host would then automatically destroy the Explosion when
its animation finished, obviating the need for synchronization.
Note, in theory, having the same random seeds on client and host
could mean that even random events (controlled
by random()
do not have to be synchronized, but in
practice, this requires event actions to take place at specific game
clock times. Such timed delivery of events is not currently supported
by Dragonfly.
There are at least two options for incorporating player input on the client. The first option is to update the player's ship on the client and then synchronize this Object with the host. However, this requires the host, having the authoritative representation of the game world, to have a way "roll back" an action that is not allowed (say, because the player's ship was blocked by an opponent). Instead, the second, recommended option is to capture keystrokes normally at the client, but then send the keystrokes (integers) to the host. The host then receives the keystrokes and generates network events, that can be handled as appropriate by game objects using the host's authoritative game world.
A recommended design includes creating a Host object (derived from Object) that runs on the server computer and a Client object (also derived from Object) that runs on the client. Both register for interest in step events and network events. A Sentry object (also derived from Object) runs on both the client and host and registers for interest in step events.
onEvent()
.
Since the Host/Client had registered interest in network events, they
then are notified network data had arrived.
The Client may only be communicating player input (e.g.,
keystrokes) to the Host, but the Host needs to communicate at least
several types of messages (e.g., add object, update object, destroy
object, game over). A message format (a core component of any
client-server protocol) should be designed ahead of time, particularly
for Host-to-Client communication (Client-to-Host may just be sending
keystrokes, each an integer). Message types can be setup as
an enum
. A suggested format for using them is:
Header:
+ size
: the entire message size, in bytes, as an integer
+ message_type
: the enum message type (effectively an integer)
+ if add object, then next is: the object_type
, as a string
(e.g., "Saucer")
+ else for delete or update, then next is: the object_id
, as an
integer
Body:
+ For add and update, this is the serialized string.
By having the size first, the Client can "peek" at the network
data, not pulling it from the socket until there are at
least size
bytes available. At that time, at least one
message is complete and can be processed.
After pulling the message from the socket (via the NetworkManager
receive()
), the message type can be checked subsequently and
appropriate actions taken.
For asking questions on Dragonfly, I encourage you to use the Dragonfly Q&A forum. There, you can ask and answer questions, comment and vote for the questions of others and their answers. Both questions and answers can be revised and improved. Questions are tagged with the relevant keywords to simplify future access and organize the accumulated material - you might be able to find the answer to your question before you ask it!
Since Dragonfly uses curses, errors
written to standard output (the screen), won't be displayed properly.
Instead, you should print messages to
the Dragonfly logfile. This is done with
the LogManager's writeLog()
. The writeLog()
method has printf()
-style variable argument formatting.
The LogManager is a singleton, so you need to
call getInstance()
to use.
Remember that TCP is a stream-based protocol. As such, while a host may intend to transmit a serialized Object as a single message, it may be chunked such that the client only gets part of the message at a time. TCP provides the entire message eventually, in order, but it does not guarantee providing the data in the same chunk size in which it was transmitted.
Compilation errors such as "Redeclaration of class
ClassName..." (with the actual class name instead of ClassName)
typically occur if the header file, say ClassName.h
, has
been included from multiple source files. This error occurs most
often for utility-type classes (and functions) that are used by
multiple other classes (e.g., the NetworkManager). The fix is
typically to use conditional compilation directives for the compiler
pre-processor. If conditional compilation directives are in use, they
should be checked that the names used in the #ifndef
and
#define
statements are identical.
Remember to error check all system calls
(e.g., send()
). Where appropriate, messages should be
written to the Dragonfly logfile (using the
LogManager writeLog()
).
If using TCP, remember TCP is a stream-oriented protocol, a client (or server) can receive part of a message (say, a serialized Object) even though the server sends only complete messages. You'll often need to be sure a complete message has arrived before processing. If using UDP, message boundaries are preserved, but remember that messages may be lost.
Sockets are by default blocking, meaning if a process reads from
them and there is no data available, the process blocks until there is
data. Non-blocking behavior can be achieved with
the MSG_DONTWAIT
flag for a recv()
call.
The ioctl()
call can be used to see the number of
bytes available in a socket before reading using the FIONREAD
parameter as the second argument (e.g., ioctl(sock, FIONREAD,
&bytes)
).
By default, a recv()
call that retrieves data from a
socket removes it from the OS buffer such that subsequent reads do not
get the same data. The MSG_PEEK
can be used to retrieve
the data but leave it in the buffer for subsequent reads.
For namespace reasons, the syntax ::
may be needed
in front of system calls (e.g., ::send(...)
).
To smooth out unexplained visual "glitches" in game state synchronization, it may be effective to occasionally synchronize all Objects on the client with those on the host.
For convenience in development, consider testing connections
via localhost
before actually testing with separate
computers.
Although not part of this assignment, you can learn a lot about game programming, and programming in general, by making your own game engine. If you are interested in building your own Dragonfly, you might consider the book, Dragonfly - Program a Game Engine from Scratch.
The slide deck for project 4 has additional background information on Dragonfly that is not in this writeup.
After you have successfully implemented (and tested!) your Dragonfly network extensions and two-player Saucer Shoot 2 game, you then design experiments to measure: 1) the network data rate from server to client, 2) the network data rate from client to server, 3) the in-game round trip time.
For all measurements, you need to consider in-game aspects, such as data rate over time and gameplay during measurements. In other words, the amount of network traffic will depend upon the number of Objects in the game world (e.g., more and more saucers as time progresses) and the player actions (e.g., frantic moving and shooting) may depend upon the same.
To measure the network data rates, consider instrumenting your code to write data out to a logfile for each packet sent/received. Analysis would then provide information on packet sizes, packet rates and bitrates. Provide at least one graph of network bitrate (e.g., Kb/s) over time.
To measure in-game round trip time, consider timing from when a
player inputs a key until the result of that action is drawn to the
screen. Logfile messages placed at the right points in the
client-side code should be able to help ascertain this. Multiple
measurements should be provided, with analysis on the average and
standard deviation, as well as the minimum and maximum. The system
call gettimeofday()
can be used to obtain the system
time.
When your experiments are complete, you must turn in a brief (1-2 page) write-up with the following sections:
Assignments are to be submitted electronically on the day due.
Important! Before you are ready to submit, make sure your game system works on the CCC machines.
All submissions must include the following:
A source code package:
.h
files.
Makefile
for building your game engine modification.
Game code for Saucer Shoot 2.
Makefile
for building your game.
A README file explaining: platform, files, code structure, how to compile, your programs. The README must also explain how to run your game. Be sure to provide anything else needed to understand (and grade) your project.
Include your experiment writeup in a clearly labeled file. Use either text, Microsoft Word, postscript or pdf format.
Use tar
with gzip
to archive your files.
For example:
mkdir lastname-proj4 cp * lastname-proj4 /* copy all the files you want to submit */ tar czvf proj4-lastname.tgz lastname-proj4 /* bundle & compress */Submit your assignment (
proj4-lastname.tgz
):
/cs/bin/turnin submit cs4513 project4 proj4-lastname.tgz
If you need more information, see Using the turnin Program for additional help with turnin.
An approximate breakdown of grades is as follows:
Grading Guidelines | |
---|---|
The "Networking Support" category primarily includes socket-based code that integrates with Dragonfly as a Manager.
The "Saucer Shoot 2" category includes integrating the networking aspects, including distributed synchronization of Objects, into Saucer Shoot. It also includes enhancing the gameplay of Saucer Shoot to incorporate a second player.
The "Miscellaneous" category is for flexibility in assigning points across the networking support and the game. Extra networking features (e.g., multiple sockets, TCP and UDP, multicast), game enhancements (e.g., GameOver, GameStart and Nukes, UI for Host/Client) or experiments (e.g., range of system conditions such networking, end-host, gameplay types) will produce points here. If everything is done to a basic, minimal level, there will be no points earned in this category.
Below is a general grading rubric:
90-100 The submission clearly exceeds requirements. The functionality is fully implemented and is provided in a robust, bug-free fashion. Full client-host synchronization is evident in the game. Gameplay is effective and fun for two players. All code is well-structured and clearly commented. Experiments effectively test all required measurements. Experimental writeup has the three required sections, with each clearly written and the results clearly depicted.
89-80 The submission meets requirements. The basic functionality is implemented and runs as expected without any critical bugs. Client-host synchronization is effective, but there may be occasional visual glitches that are not critical to gameplay. Gameplay is effective for two players. Code is well-structured and clearly commented. Experimental writeup has the three required sections, with details on the methods used and informative results.
79-70 The submission barely meets requirements. Functionality is mostly implemented, but may not be fully implemented and/or may not run as expected. Client-host synchronization provides occasional visual glitches, some may be critical to gameplay. Gameplay supports two players, but to a limited extent. Code is only somewhat well-structured and commented. Experiments are incomplete and/or the writeup does not provide clarity on the methods or results.
69-60 The project fails to meet requirements in some places. Networking support is missing critical functionality or robustness. The engine may crash occasionally. The game does not support complete or robust gameplay for two players. Code is lacking in structure or comments. Experiments are incomplete and the writeup does not provide clarity on the methods or results.
59-0 The project does not meet core requirements. The networking extensions cannot compile, crashes consistently, or is lacking many functional features. The game does not compile or does not support two player interaction. Code is only lacking structure and comments. Experiments are incomplete with a minimal writeup.
Send all questions to the TA mailing list (cs4513-staff at cs.wpi.edu).