Click here to Skip to main content
Click here to Skip to main content
Go to top

XNA Snooker Club

, 8 Mar 2010
Rate this:
Please Sign up or sign in to vote.
WCF-enabled multi-player XNA game for the Windows platform.

XNASnookerClub_SRC

Contents

1. Introduction

This is my very first XNA application, even though it started with a porting from my previous Windows Forms game.

It took me about 2 months to create this XNA game, and there are so many interesting features in it that I think would render two articles. But instead, I decided to keep the article as short as possible. Besides, many of the game concepts have been already covered in the other article, so please refer to the C# Snooker article if you feel some of the content is missing.

If you are a developer, I hope the concepts explained here can be useful someday. If you are an addicted gamer, my goal here is to make you spend much of your time having fun with it.

For a better explanation, I uploaded a video to YouTube (me against the computer), so I hope it can save you time in understanding the concepts presented here:

2. Acknowledgements

Some people have made my life a lot easier. Special thanks to Matthew McDole for his detection collision and collision handling article. Also, the WCF part got much easier after I understood Sacha Barber's WCF / WPF Chat Application, which looks cool and amazing even after some years since it's been published.

Many thanks to my sweet 10 year old niece Ana Beatriz, who's a big fan of games and helped me test the multiplayer feature for 2 weeks.

Special thanks to my friend Rohit Dixit, who's currently working on the Gaming 123 website to host many free games, including XNA Snooker. Rohit is now developing rooms so that logged on players could connect to each other and play games.

3. System Requirements

The system requirements depend on what you want to do:

XNA Client

XNA Introduction

If you are a .NET developer, XNA should be the first option when it comes to writing a game, even if you have no previous experience with the tool. As a newbie, I had prejudiced thoughts about it. But moving to XNA was far easier than I thought. It's frustrating when a technology doesn't follow your creativity, but XNA is different. When it comes to developing a game, you don't ask "is it possible to do it with XNA?" but rather "which technique should I learn to make it work with XNA?". No kidding. I would say that any top commercial game could be made with XNA, you would just need to know how to use the right techniques and, if you are developing for PC, you might need a good video card.

XNA Controls

Unlike Windows Forms and WPF, the XNA framework lacks the concept of "UI controls". So, it's up to you to implement your own UI controls. The good news is that I just needed two types of controls: a radio button and a command button. Since I wanted to keep it very simple, both controls just render a string on the screen:

Figure 1. XNA controls (XNARadioButton and XNAButton) in the main menu

Notice that XNARadioButton starts with a "[ ]" string. This is so because the user can make it checked. Once it's checked, the "[ ]" becomes "[x]".

Also, the command button (XNAButton) has nothing more than a string. When the user moves the control over it, it gets highlighted.

Simple and efficient, but maybe I'll replace them later with more appealing controls.

Figure 2. XNA controls diagram

Sounds

In order to use sounds in your game, you have to insert the original .wav files using the Microsoft Cross-Platform Audio Creation Tool (XACT):

Figure 3. Microsoft Cross-Platform Audio Creation Tool (XACT)

Once the game is built, the .wav files are automatically converted into .xnb files, which can then be played by XNA.

Different sounds can be played depending on the events occurring in the game:

Figure 4. Sounds played by XNA Snooker Club

Visual Keyboard

When the user enters the game, he or she is asked to provide the player name, and then is presented the Visual Keyboard. This could be done using the standard keyboard's keypress or keydown events, but instead, I made this Visual Keyboard, which after all looked nice on the screen.

Figure 5. Visual Keyboard

One important thing is that the keyboard is made of a single sprite. When the mouse moves over some key, the VisualKeyboard class identifies the key, and the main game class highlights a yellow sprite right below that key in the keyboard sprite. Also, a soft "click" sound indicates when a key has been selected.

char? GetChar(Vector2 position)
{
    char? ret = null;
    int i = 0;
    foreach (char c in KEYS1)
    {
        if (position.X >= keys1StartPosition.X && 
            position.X <= keys1StartPosition.X + 
              i * keysHSpace + keySize.X &&
            position.Y >= keys1StartPosition.Y && 
            position.Y <= keys1StartPosition.Y + keySize.Y)
        {
            ret = c;
            observer.HighLightKey(new Vector2(keys1StartPosition.X + 
                                  i * keysHSpace, 
                                  keys1StartPosition.Y));
            break;
        }
        i++;
    }
    if (ret == null)
    {
        i = 0;
        foreach (char c in KEYS2)
        {
            //more code goes here...
        }

Player Photo

The game allows you to replace the default "No Photo Available" image. Just copy some image to the Clipboard (go to Windows Explorer, select the file, and then press Ctrl+C, or if you prefer, select an image area through some graphics application, such as Paint.Net), and then click the player picture box while in the Sign In screen.

Figure 6. Player picture, copied from Clipboard

Notice that vertical black bars where inserted at both sides of the original picture. This is so because the size ratio (width / height) is different from the size ratio of the target picture. Otherwise, horizontal black bars would be inserted at the top and at the bottom. This is how the original aspect ratio is preserved.

The code here covers how the images are copied from Clipboard and transferred to the texture in the game:

System.Windows.Forms.IDataObject iDataObject = 
               System.Windows.Forms.Clipboard.GetDataObject();
Drawing.Bitmap sourceBitmap = null;
if (iDataObject.GetDataPresent(System.Windows.Forms.DataFormats.FileDrop))
{
    string[] fileNames = iDataObject.GetData(
      System.Windows.Forms.DataFormats.FileDrop, true) as string[];
    if (fileNames.Length > 0)
    {
        string photoPath = fileNames[0];
        try
        {
            sourceBitmap = new Drawing.Bitmap(photoPath);
        }
        catch { }
    }
}
else if (iDataObject.GetDataPresent(System.Windows.Forms.DataFormats.Bitmap))
{
    sourceBitmap = (Drawing.Bitmap)iDataObject.GetData(
                       System.Windows.Forms.DataFormats.Bitmap);
}
 
//if the bitmap doesn't get loaded,
//then or the file is corrupted or is of a wrong type,
//so we'll just ignore that image.
if (sourceBitmap != null)
{
    float sourcePictureRatio = 
         (float)sourceBitmap.Width / (float)sourceBitmap.Height;
    float targetPictureRatio = (float)team1Player1PictureRectangle.Width / 
         (float)team1Player1PictureRectangle.Height;
 
    Drawing.Image targetImage = new Drawing.Bitmap(
      team1Player1PictureRectangle.Width, 
      team1Player1PictureRectangle.Height);
    Drawing.Image resizedImage = null;
 
    //resizing to fit into the target rectangle
    using (Drawing.Graphics g = Drawing.Graphics.FromImage(targetImage))
    {
        g.Clear(Drawing.Color.Black);
        if (sourcePictureRatio < targetPictureRatio)
        {
            float scale = (float)team1Player1PictureRectangle.Height / 
                          (float)sourceBitmap.Height;
            //resizedImage = new Drawing.Bitmap(sourceBitmap, 
            //   new Drawing.Size((int)(sourceBitmap.Width * scale),
            //   (int)(sourceBitmap.Height * scale)));
            resizedImage = new Drawing.Bitmap(sourceBitmap, 
              new Drawing.Size((int)(sourceBitmap.Width * scale), 
              (int)(sourceBitmap.Height * scale)));
            g.DrawImage(resizedImage, new Drawing.Point((
              targetImage.Size.Width - resizedImage.Width) / 2, 0));
        }
        else
        {
            float scale = (float)team1Player1PictureRectangle.Width / 
                          (float)sourceBitmap.Width;
            resizedImage = new Drawing.Bitmap(sourceBitmap, 
              new Drawing.Size((int)(sourceBitmap.Width * scale), 
              (int)(sourceBitmap.Height * scale)));
            g.DrawImage(resizedImage, new Drawing.Point(0, 
              (targetImage.Size.Height - resizedImage.Height) / 2));
        }
    }
 
    targetImage.Save("tempImage");
    signInPictureSprite.Texture = 
      Texture2D.FromFile(GraphicsDevice, "tempImage");
    Texture2D texture = signInPictureSprite.Texture;
    signInPlayer.ImageByteArray = new byte[4 * texture.Width * texture.Height];
    signInPlayer.Texture = signInPictureSprite.Texture;
    texture.GetData<byte>(signInPlayer.ImageByteArray);
    clickHereSprite.Texture = null;
}
System.Windows.Forms.Clipboard.Clear();

Ball Rendering

It would be nice if the game looked as much real as possible. Despite the fact that it's a 2D game, it was possible to take advantage of some techniques to render a real-looking (or almost!) snooker game.

The balls rendering represent a vital part in this game. If you take a look at the table below, you'll see that the rendering is not made at once, but in a few steps:

Here we have the table fabric prior to the ball.

Then, we must place the shadows cast by the four lights (one at each corner of the table).

Now, here's the ball over the background, without smoothing. Notice that the borders are a bit "pixelated".

Finally, we apply an "alpha blending" technique around the ball in order to make it smoother.
Figure 7. Rendering the ball with different layers

That being said, here comes the MoveBalls function, which is responsible for calculating the ball positions in a given instant (snapshot).

Notice that this is the heart and soul of the game, because it has to resolve collisions (between ball and ball and ball and borders), calculate ball velocity based on the current velocity and the friction coefficient, calculate the vertical spin velocity, and decide whether the balls are still moving or not.

private List<SnookerCore.BallPosition> MoveBalls()
{
    //Instantiate a new ball position list, 
    //that will be returned at the end of the function.
    List<SnookerCore.BallPosition> ballPositionList = 
                        new List<SnookerCore.BallPosition>();

    //Flag indicating that the program is still calculating
    //the positions, that is, the balls are still in an inconsistent state.
    calculatingPositions = true;

    foreach (Ball ball in ballSprites)
    {
        if (Math.Abs(ball.X) < 5 && Math.Abs(ball.Y) < 5 && 
            Math.Abs(ball.TranslateVelocity.X) < 10 && 
            Math.Abs(ball.TranslateVelocity.Y) < 10)
        {
            ball.X =
            ball.Y = 0;

            ball.TranslateVelocity = new Vector2(0, 0);
        }
    }

    bool conflicted = true;

    //process this loop as long as some balls are still colliding
    while (conflicted)
    {
        conflicted = false;

        bool someCollision = true;
        while (someCollision)
        {
            foreach (Ball ball in ballSprites)
            {
                foreach (Pocket pocket in pockets)
                {
                    bool inPocket = pocket.IsBallInPocket(ball);
                }
            }

            someCollision = false;
            foreach (Ball ballA in ballSprites)
            {
                if (ballA.IsBallInPocket)
                {
                    ballA.TranslateVelocity = new Vector2(0, 0);
                }

                //Resolve collisions between balls and the diagonal borders.
                //the diagonal borders are the borders near the pockets.
                foreach (DiagonalBorder diagonalBorder in diagonalBorders)
                {
                    if (diagonalBorder.Colliding(ballA) && !ballA.IsBallInPocket)
                    {
                        diagonalBorder.ResolveCollision(ballA);
                    }
                }

                //Resolve collisions between balls 
                //and each of the 6 borders in the table
                RectangleCollision borderCollision = RectangleCollision.None;
                foreach (TableBorder tableBorder in tableBorders)
                {
                    borderCollision = tableBorder.Colliding(ballA);

                    if (borderCollision != RectangleCollision.None && 
                        !ballA.IsBallInPocket)
                    {
                        someCollision = true;
                        tableBorder.ResolveCollision(ballA, borderCollision);
                    }
                }

                //Resolve collisions between balls
                foreach (Ball ballB in ballSprites)
                {
                    if (ballA.Id.CompareTo(ballB.Id) != 0)
                    {
                        if (ballA.Colliding(ballB) && 
                            !ballA.IsBallInPocket && !ballB.IsBallInPocket)
                        {
                            if (ballA.Points == 0)
                            {
                                strokenBalls.Add(ballB);
                            }
                            else if (ballB.Points == 0)
                            {
                                strokenBalls.Add(ballA);
                            }

                            while (ballA.Colliding(ballB))
                            {
                                someCollision = true;
                                ballA.ResolveCollision(ballB);
                            }
                        }
                    }
                }

                //If the ball entered the pocket, it must be stopped
                if (ballA.IsBallInPocket)
                {
                    ballA.TranslateVelocity = new Vector2(0, 0);
                    ballA.VSpinVelocity = new Vector2(0, 0);
                }

                //Calculate ball's translation velocity (movement) 
                //as well as the spin velocity.
                //The friction coefficient is used to decrease ball's velocity
                if (ballA.TranslateVelocity.X != 0.0d ||
                    ballA.TranslateVelocity.Y != 0.0d)
                {
                    float signalXVelocity = 
                      ballA.TranslateVelocity.X >= 0.0f ? 1.0f : -1.0f;
                    float signalYVelocity = 
                      ballA.TranslateVelocity.Y >= 0.0f ? 1.0f : -1.0f;
                    float absXVelocity = Math.Abs(ballA.TranslateVelocity.X);
                    float absYVelocity = Math.Abs(ballA.TranslateVelocity.Y);

                    Vector2 absVelocity = new Vector2(absXVelocity, absYVelocity);

                    Vector2 normalizedDiff = new Vector2(absVelocity.X, absVelocity.Y);
                    normalizedDiff.Normalize();

                    absVelocity.X = absVelocity.X * (1.0f - friction) - 
                                    normalizedDiff.X * friction;
                    absVelocity.Y = absVelocity.Y * (1.0f - friction) - 
                                    normalizedDiff.Y * friction;

                    if (absVelocity.X < 0f)
                        absVelocity.X = 0f;

                    if (absVelocity.Y < 0f)
                        absVelocity.Y = 0f;

                    float vx = absVelocity.X * signalXVelocity;
                    float vy = absVelocity.Y * signalYVelocity;

                    if (float.IsNaN(vx))
                        vx = 0;

                    if (float.IsNaN(vy))
                        vy = 0;

                    ballA.TranslateVelocity = new Vector2(vx, vy);
                }

                //Calculate ball's translation velocity (movement) 
                //as well as the spin velocity.
                //The friction coefficient is used to decrease ball's velocity
                if (ballA.VSpinVelocity.X != 0.0d || ballA.VSpinVelocity.Y != 0.0d)
                {

                    float signalXVelocity = 
                      ballA.VSpinVelocity.X >= 0.0f ? 1.0f : -1.0f;
                    float signalYVelocity = 
                      ballA.VSpinVelocity.Y >= 0.0f ? 1.0f : -1.0f;
                    float absXVelocity = Math.Abs(ballA.VSpinVelocity.X);
                    float absYVelocity = Math.Abs(ballA.VSpinVelocity.Y);

                    Vector2 absVelocity = new Vector2(absXVelocity, absYVelocity);

                    Vector2 normalizedDiff = new Vector2(absVelocity.X, absVelocity.Y);
                    normalizedDiff.Normalize();

                    absVelocity.X = absVelocity.X - normalizedDiff.X * friction / 1.2f;
                    absVelocity.Y = absVelocity.Y - normalizedDiff.Y * friction / 1.2f;

                    if (absVelocity.X < 0f)
                        absVelocity.X = 0f;

                    if (absVelocity.Y < 0f)
                        absVelocity.Y = 0f;

                    ballA.VSpinVelocity = new Vector2(absVelocity.X * signalXVelocity, 
                                          absVelocity.Y * signalYVelocity);
                }
            }

            //Calculate the ball position based on both the ball's
            //translation velocity and vertical spin velocity.
            foreach (Ball ball in ballSprites)
            {
                ball.Position += new Vector2(ball.TranslateVelocity.X + 
                                             ball.VSpinVelocity.X, 0f);
                ball.Position += new Vector2(0f, ball.TranslateVelocity.Y + 
                                             ball.VSpinVelocity.Y);
            }
        }

        MoveBall(false);
        conflicted = false;
    }

    double totalVelocity = 0;
    foreach (Ball ball in ballSprites)
    {
        totalVelocity += ball.TranslateVelocity.X;
        totalVelocity += ball.TranslateVelocity.Y;
    }

    calculatingPositions = false;

    //If no balls are moving anymore, then the poos
    //state is set to "awaiting shot",
    //so that the game can wait for another shot from the same player of from the
    //other player.
    if (poolState == PoolState.MovingBalls && totalVelocity == 0)
    {
        if (poolState == PoolState.MovingBalls)
        {
            MoveBall(true);
            poolState = PoolState.AwaitingShot;
        }
    }

    ballPositionList = GetBallPositionList();

    //Return the resulting ball position list, so that
    //it can be added to the current snapshot.
    return ballPositionList;
}

Cue Movement

One of the cool features in the game, in my opinion, is the cue movement.

The cue moving around the cue ball close to it (notice the cue casting shadow over the table).

The cue getting away from the cue, preparing to shoot.

The cue moves freely around the cue ball, following the same direction as the mouse pointer moves. Once the player selects the target, the cue locks in that direction, gets away from the cue for a little while, and then strikes the ball in the desired direction.

The cue also casts a shadow over the table. Besides, it also has a smooth rendering against the table background.

Playing Modes

Single Player

Imagine that you have released your game, and that it has only the multiplayer mode. Now, imagine a poor player, always asking his/her friends to a game... And the solution to this problem is...

A.I.

...to implement some kind of artificial intelligence, to challenge your player's intelligence. So, even if your game is intended to have a multiplayer mode, you should also consider having a single player mode. Players love challenges. But, what if you have gone too far with your A.I., and your game got so "intelligent" to the point that your player eventually gives up your game once and for all, completely humiliated and disappointed?

Levels: Easy, Normal, and Hard

The answer to that last question is: break the challenge into levels. Just like most games, there are three difficulty levels: easy, normal, and hard. The difference between them is too simple: given a number of "ghost balls", the computer randomly picks one of them and internally simulates a shot. Then it calculates the yielded result (the won points and the lost points in that shot). It's easy to beat the computer in this mode. In the case of the Easy mode, the computer has only one chance. In Normal mode, the computer generates 10 simulations and calculates which one has the best results. But, in the Hard mode, the computer simulates up to 20 shots. So, you'll have to be a very good player to beat it (please tell me later if you have succeeded...).

A.I. Strategy

I must confess I never had any training on Artificial Intelligence. In fact, I don't even know if this could be called A.I., but what the computer actually does when it's playing in its turn is to randomly simulate a number of shots and decide which one is better. The crucial point is this:

//decide whether this shot was better then the current best shot
if (shot.LostPoints < teams[playingTeamID - 1].BestShot.LostPoints || 
    shot.WonPoints > teams[playingTeamID - 1].BestShot.WonPoints)
{
    teams[playingTeamID - 1].BestShot.LostPoints = shot.LostPoints;
    teams[playingTeamID - 1].BestShot.WonPoints = shot.WonPoints;
    teams[playingTeamID - 1].BestShot.Position = shot.Position;
    teams[playingTeamID - 1].BestShot.Strength = shot.Strength;
}

For the complete function, see the code snippet below:

/// <summary>
/// Generate computer shot, based on the difficulty level and on the current
/// ball positions, as well as on random variables
/// </summary>
private void GenerateComputerShot()
{
    cueDistance = 0;

    List<Ball> auxBalls = new List<Ball>();

    //Saving current ball positions in auxBalls list,
    //so that it could be restored later,
    //after the simulations
    auxBalls.Clear();
    foreach (Ball b in ballSprites)
    {
        Ball auxBall = new Ball(null, null, null, null, 
             new Vector2(b.Position.X, b.Position.Y), 
             new Vector2((int)Ball.Radius, (int)Ball.Radius), b.Id, null, 0);
        auxBall.IsBallInPocket = b.IsBallInPocket;
        auxBalls.Add(auxBall);
    }

    //In order to decide whether the simulation was a good shot or not,
    //we must store the scores both before and after the simulations.
    int lastPlayerScore = teams[playingTeamID - 1].Points;
    int lastOpponentScore = teams[awaitingTeamID - 1].Points;
    int player1Score = teams[0].Points;
    int player2Score = teams[1].Points;
    string ballOnId = teams[playingTeamID - 1].BallOn.Id;

    int newPlayerScore = -1;
    int newOpponentScore = 1000;

    //This lines show the AI strategy: first, the simulations
    //aim to win points. If it's not possible, they aim not to loose
    //points. In the end, it try the "despair" mode, 
    //using reflected (mirrored) ghost balls technique.
    teams[playingTeamID - 1].Attempts++;
    if (teams[playingTeamID - 1].AttemptsToWin < maxComputerAttempts)
    {
        teams[playingTeamID - 1].AttemptsToWin++;
    }
    else if (teams[playingTeamID - 1].AttemptsNotToLose < maxComputerAttempts)
    {
        teams[playingTeamID - 1].AttemptsNotToLose++;
    }
    else
    {
        teams[playingTeamID - 1].AttemptsOfDespair++;
    }

    teams[playingTeamID - 1].Points = lastPlayerScore;
    teams[awaitingTeamID - 1].Points = lastOpponentScore;
    foreach (Ball b in ballSprites)
    {
        if (b.Id == ballOnId)
        {
            teams[playingTeamID - 1].BallOn = b;
            break;
        }
    }
    teams[0].Points = player1Score;
    teams[1].Points = player2Score;

    bool despair = (teams[playingTeamID - 1].AttemptsOfDespair > 0);

    UpdateGameState(GameState.TestShot);
    TestShot shot = GenerateRandomTestComputerShot(despair);
    teams[playingTeamID - 1].LastShot = shot;

    //If it's game over, there's no shot to simulate!
    if (shot == null) // Game Over
    {
        teams[playingTeamID - 1].BestShot = null;
        UpdateGameState(GameState.GameOver);
    }
    else
    {
        //Calculate positions and exit only when the balls are in a
        //consistent state.
        while (poolState == PoolState.MovingBalls)
        {
            MoveBalls();
        }

        calculatingPositions = false;

        //Calculate last shot data, including scores.
        ProcessFallenBalls();

        newPlayerScore = teams[playingTeamID - 1].Points;
        newOpponentScore = teams[awaitingTeamID - 1].Points;

        shot.WonPoints = newPlayerScore - lastPlayerScore;
        shot.LostPoints = newOpponentScore - lastOpponentScore;
        cueSprite.NewTarget = new Vector2(shot.Position.X, shot.Position.Y);

        double dx = ballSprites[0].DrawPosition.X - shot.Position.X;
        double dy = ballSprites[0].DrawPosition.Y - shot.Position.Y;
        double h = Math.Sqrt(dx * dx + dy * dy);
        teams[playingTeamID - 1].FinalCueAngle = (float)Math.Acos(dx / h);


        //decide whether this shot was better then the current best shot
        if (shot.LostPoints < teams[playingTeamID - 1].BestShot.LostPoints ||
            shot.WonPoints > teams[playingTeamID - 1].BestShot.WonPoints)
        {
            teams[playingTeamID - 1].BestShot.LostPoints = shot.LostPoints;
            teams[playingTeamID - 1].BestShot.WonPoints = shot.WonPoints;
            teams[playingTeamID - 1].BestShot.Position = shot.Position;
            teams[playingTeamID - 1].BestShot.Strength = shot.Strength;
        }

        int i = 0;
        foreach (Ball b in ballSprites)
        {
            Ball auxB = auxBalls[i];
            b.Position = new Vector2(auxB.Position.X, auxB.Position.Y);
            b.IsBallInPocket = auxB.IsBallInPocket;
            i++;
        }

        if (newPlayerScore > lastPlayerScore ||
            newOpponentScore == lastOpponentScore && 
            (teams[playingTeamID - 1].AttemptsToWin >= maxComputerAttempts) ||
            teams[playingTeamID - 1].AttemptsOfDespair > maxComputerAttempts
            )
        {
            teams[playingTeamID - 1].BestShotSelected = true;
            teams[playingTeamID - 1].LastShot = teams[playingTeamID - 1].BestShot;
        }
    }

    teams[playingTeamID - 1].Points = lastPlayerScore;
    teams[awaitingTeamID - 1].Points = lastOpponentScore;
    teams[0].Points = player1Score;
    teams[1].Points = player2Score;
    foreach (Ball b in ballSprites)
    {
        if (b.Id == ballOnId)
        {
            teams[playingTeamID - 1].BallOn = b;
            break;
        }
    }

    int j = 0;
    foreach (Ball b in ballSprites)
    {
        Ball auxB = auxBalls[j];
        b.Position = new Vector2(auxB.Position.X, auxB.Position.Y);
        b.IsBallInPocket = auxB.IsBallInPocket;
        j++;
    }

    //Proceed with a real shot using the best simulated shot data
    if (teams[playingTeamID - 1].BestShotSelected && 
        teams[playingTeamID - 1].BestShot != null)
    {
        hitPosition = new Vector2(teams[playingTeamID - 1].BestShot.Position.X, 
                      teams[playingTeamID - 1].BestShot.Position.Y);
        cueSprite.NewTarget = new Vector2(teams[playingTeamID - 1].BestShot.Position.X + 
                              poolRectangle.X - 7, 
                              teams[playingTeamID - 1].BestShot.Position.Y + 
                              poolRectangle.Y - 7);

        ghostBallSprite.Position = new Vector2(hitPosition.X + poolRectangle.X - 7, 
                                   hitPosition.Y + poolRectangle.Y - 7);

        teams[playingTeamID - 1].Strength = teams[playingTeamID - 1].BestShot.Strength;
        this.playerState = PlayerState.Aiming;

        teams[playingTeamID - 1].LastShot = teams[playingTeamID - 1].BestShot;

        UpdateCuePosition(0, (int)teams[playingTeamID - 1].BestShot.Position.X + 
                          poolRectangle.X, 
                          (int)teams[playingTeamID - 1].BestShot.Position.Y + 
                          poolRectangle.Y);

        poolState = PoolState.PreparingCue;
    }
    teams[playingTeamID - 1].IsRotatingCue = true;
}

Despair Mode

If all attempts of the computer result in failure (that is, in lost points), then it is given a last chance. This is what I call the final "despair mode". In this mode, the computer will shoot against reflected ("or mirrored") ghost balls.

Ghost Balls

Figure 8. Ghost balls

When the computer gives a shot, it doesn't do so aimlessly. It's not every position in the pool that can be aimed at, because that would be too inefficient. This is why the computer concentrates its efforts in the "ghost balls": they are calculated as the circle that touches the object balls in only one point, so that if you draw a straight line from the center of the ghost ball to the pocket position, that line would include the point of the center of the object ball (see the above picture).

In figure 8 above, the ghost balls are represented by the white circles near the balls. They are represented by implementations of the same Ball class, just like any other ball in the table. The difference is that they are not rendered.

Reflected Ghost Balls

As mentioned before, in "despair" mode, only the reflected ghost balls can be aimed at. This means that the computer is given a chance to create an alternative shot that might result in a better result.

Once a shot is aimed at a reflected ghost ball, the cue ball path is "mirrored" at the pool border, so instead of reaching the reflected ghost ball, the cue ball hits the real ghost ball:

Figure 9. Reflected ghost balls

The following piece of code shows part of this implementation:

foreach (Ball ballOn in ballOnList)
{
    List<ball> tempGhostBalls = GetGhostBalls(ballOn, false);
    if (!despair)
    {
        foreach (Ball ghostBall in tempGhostBalls)
        {
            ghostBalls.Add(ghostBall);
        }
    }
    else
    {
        //reflected by the top border
        Ball mirroredBall = new Ball(null, null, null, null, 
        new Vector2((int)(ballOn.X - Ball.Radius), (int)(-1.0 * ballOn.Y)), 
        new Vector2((int)Ball.Radius, (int)Ball.Radius), "m1", null, 0);
        tempGhostBalls = GetGhostBalls(mirroredBall, despair);
        foreach (Ball ghostBall in tempGhostBalls)
        {
            ghostBalls.Add(ghostBall);
        }

Cue Movement Emulation

The icing on the top of the Artificial Intelligence that we've just seen, is to make the computer behave a bit like a human. Usually, a human player would look at the possibilities, calculate, move the cue, calculate again, move the cue back to the last position, and only after a little while play a shot. This is exactly what the computer does in the game. In its turn, you'll see the cue moving around and around, aiming here and there, and only then shooting.

You'll see that in the Easy mode, the computer plays with carelessness. In the hard mode, on the other hand, it usually takes longer for the computer to play, because it is "thinking" of the best possibility.

Multiplayer

Although the Multiplayer mode in XNA Snooker Club was intended to run on different machines, it's possible to run the WCF Service and two XNA clients on the same machine, like in the picture below:

WCF Service / WCF Client

Although it could be fun to play against the computer, two (human) players can also play XNA Snooker Club against each other, thanks to Windows Communication Foundation. But the clients don't connect directly to each other. Instead, they rely on a WCF service running on a different application to broadcast the messages.

It should be said that my job in doing this WCF implementation became much easier after I finished reading Sacha Barber's great article: WCF / WPF Chat Application. So, you'll find similarities between the two implementations. In Sacha's chat application, the clients establish connection by joining the same WCF service. The conversation is done while the messages are sent by clients and broadcasted by that service to the joined chatters. In XNA Snooker Club, on the other side, the clients connect to the snooker service, which in turn broadcasts the game movements, score, sounds, etc. This exchange between snooker players is also a form of conversation.

It's a very nice thing that WCF services offer a variety of hosting options: console, Windows Forms, WPF, Windows Services, IIS, self-hosting... The WCF snooker service is hosted by a console application. The good thing about console applications is its simplicity. They are easy to create and run, and very handy when you need to write messages to the output.

static void Main(string[] args)
{
    // Get host name
    String strHostName = Dns.GetHostName();
 
    // Find host by name
    IPHostEntry iphostentry = Dns.GetHostEntry(strHostName);
 
    // Enumerate IP addresses
    int nIP = 0;
    foreach (IPAddress ipaddress in iphostentry.AddressList)
    {
        Console.WriteLine("Server IP: #{0}: {1}", ++nIP, ipaddress);
    }
 
    //Concatenates the configuration address 
    //with the ip obtained from this server
    uri = new Uri(string.Format(
            ConfigurationManager.AppSettings["address"], strHostName));
 
    ServiceHost host = new ServiceHost(typeof(SnookerService), uri);
    host.Opened += new EventHandler(host_Opened);
    host.Closed += new EventHandler(host_Closed);
    host.Open();
    Console.ReadLine();
    host.Abort();
    host.Close();
}

Figure 10. WCF Service running
Service Agent

The XNA Snooker Club VS2008 solution is separated into two application layers: the Core project and the XNA project. The WCF client resides on the Core layer. It is responsible for establishing connection with the WCF service, as well as to send and receive messages from the service.

The WCF client knows how to communicate with the WCF Service, thanks to the proxy class. This class can be generated automatically by using the svcutil command line utility, or using the Add Service Reference menu in Visual Studio 2008.

Once the proxy is created, the client can start calling the service methods. But instead, it's a best practice to apply the Service Agent pattern. A service agent is a component on the client side that wraps the proxy methods and performs additional processing in order to further separate client code from the service. In our case, the service agent is a singleton class residing on the Core project.

Service Contract

The WCF service class is an implementation of the ISnooker interface:

[ServiceContract(SessionMode = SessionMode.Required, 
        CallbackContract = typeof(ISnookerCallback))]
interface ISnooker
{
    [OperationContract(IsOneWay = true, 
     IsInitiating = false, IsTerminating = false)]
    void Play(ContractTeam team, ContractPerson name, Shot shot);
 
    [OperationContract(IsOneWay = false, 
     IsInitiating = true, IsTerminating = false)]
    ContractTeam[] Join(ContractPerson name);
 
    [OperationContract(IsOneWay = true, 
     IsInitiating = false, IsTerminating = true)]
    void Leave();
}

First, the ServiceContract attribute that decorates the interface defines the operations the service makes available to be invoked as requests are sent to the service from the client. The session mode is set to Required, which indicates to the contract that a session is to be maintained (this is how a conversation is possible).

The CallbackContract sets up the infrastructure for a duplex MEP (message exchange pattern), and this is how it allows the service to initiate sending messages (e.g., notifications about a shot that was just played by a player) to the client.

The Join operation allows the client to join a game. If the current game on the WCF side already has two players, the join operation is refused.

As a response to the Join operation, the service returns the information about the current teams and players.

When the player shoots the cue ball, the XNA application starts recording the ball position movements in a structure stored in the Shot class. This recording goes on as long as there are moving balls on the table.

Once the player has finished a shot (that is, when all balls become still), the Play operation is invoked, so the WCF client calls the Shot in the proxy with a Shot parameter containing the recorded sequence of ball movements, along with sounds and scores.

In opposition to the Join operation, when the XNA client is exiting, it calls the Leave operation, which in turn will broadcast to the game participants a callback indicating that one of the partners has left.

Callbacks

As we have seen in the previous section, the CallbackContract for ISnooker is ISnookerCallback. This means that, when the client calls an operation on the snooker service side, it must also expect that the service calls back the XNA client with the methods defined by ISnookerCallback:

interface ISnookerCallback
{
    [OperationContract(IsOneWay = true)]
    void ReceivePlay(ContractTeam team, 
         ContractPerson person, Shot shot);
   
    [OperationContract(IsOneWay = true)]
    void UserEnter(ContractTeam team, ContractPerson person);
 
    [OperationContract(IsOneWay = true)]
    void UserLeave(ContractTeam team, ContractPerson person);
}

Notice in the figure below how a running WCF service handles the requests and broadcasts the messages back to the game participants:

Figure 11. WCF Service processing requests / responses
Outgoing Shots / Incoming Shots

As mentioned before, the ball movements are sent/received to/from the service through the Shot data contract. Notice in the class below that besides the snapshot list, there is some more information to the Shot, such as the scores and a GameOver field indicating whether the player has won the game just after that shot.

[DataContract]
public class Shot
{
    #region private members
    ...
    #endregion
 
    #region constructors
    ...
    #endregion
 
    #region public members
 
    [DataMember]
    public int TeamId
    {
        get { return teamId; } 
        set { teamId = value; }
    }
 
    [DataMember]
    public List<snapshot> SnapshotList
    {
        get { return snapshotList; } 
        set { snapshotList = value; }
    }
 
    [DataMember]
    public int CurrentTeamScore
    {
        get { return currentTeamScore; } 
        set { currentTeamScore = value; }
    }
 
    [DataMember]
    public int OtherTeamScore
    { 
        get { return otherTeamScore; } 
        set { otherTeamScore = value; }
    }
 
    [DataMember]
    public bool HasFinishedTurn
    {     
        get { return hasFinishedTurn; } 
        set { hasFinishedTurn = value; }
    }
 
    [DataMember]
    public bool GameOver
    {
        get { return gameOver; } 
        set { gameOver = value; }
    }
    #endregion
}

The outgoing shot data is held by the outgoingShot object (Shot class), and is created by the XNA client every time the players prepare to shoot.

Every time there is a movement of balls on the table, the XNA client creates a new Snapshot, which in turn holds all the ball positions on the table at a given time, along with the sounds that occur at that instant. This new Snapshot is then added to outgoingShot, as seen below:

private static void CreateSnapshot(List<SnookerCore.BallPosition> newBPList)
{
    if (rbtMultiPlayer.Checked)
    {
        List<SnookerCore.Snapshot> currentSnapshotList = 
                                  outgoingShot.SnapshotList.ToList();
        SnookerCore.Snapshot newSnapshot = new Snapshot();
        newSnapshot.ballPositionList = newBPList.ToArray();
        newSnapshot.snapshotNumber = currentSnapshotNumber;
        newSnapshot.sound = GameSound.None;
        currentSnapshotList.Add(newSnapshot);
        outgoingShot.SnapshotList = currentSnapshotList.ToArray();
        currentSnapshotNumber++;
    }
}

In multiplayer mode, everything happening on the XNA client must be enlisted in outgoingShot to be sent later to the other player. But, the outgoingShot is not sent until the shot is complete, all balls are still, and both scores have been calculated, as seen below:

//Here goes the "fade out" effect when the ball enters
//the pocket and then disappears
someFalling = ProcessSomeFalling();
//If all balls have fallen, we should prepare 
//for sending the outgoing shot info
if (!someFalling)
{
    if (lastPoolState == PoolState.MovingBalls)
    {
        if (!fallenBallsProcessed)
        {
            //Calculate both scores for the fallen balls,
            //restore illegal potted balls to their correct positions, etc.
            ProcessFallenBalls();
            foreach (Ball b in ballSprites)
            {
                b.DrawPosition = b.Position;
            }
            //Get the final position of each ball on the table
            //and add the snapshot to the outgoing shot
            CreateSnapshot(GetBallPositionList());

            //Occurrs only in multiplayer mode. In singleplayer there's no
            //need to broadcast outgoing shots.
            if (rbtMultiPlayer.Checked)
            {
                logList.Add(string.Format("Player {0} sent {1} snapshots", 
                     contractPerson.Name, outgoingShot.SnapshotList.Count()));
                //We should propagate both scores
                outgoingShot.CurrentTeamScore = teams[0].Points;
                outgoingShot.OtherTeamScore = teams[1].Points;

                //Now it's up to the WCF service 
                //to receive and broadcast this player's shot
                SnookerServiceAgent.GetInstance().Play(contractTeam, 
                                                  contractPerson, outgoingShot);

                //We must reset the outgoing shot for the next turn
                ResetOutgoingShot();
            }
        }
    }
}

As this outgoingShot object arrives at the WCF service, it is simply broadcasted to other game players.

/// <summary>
/// Broadcasts the list of ball positions to all the <see cref="Common.Person">
/// Person</see /> whos name matches the to input parameter
/// by looking up the person from the internal list of players
/// and invoking their SnookerEventHandler delegate asynchronously.
/// Where the MyEventHandler() method is called at the start of the
/// asynch call, and the EndAsync() method at the end of the asynch call
/// </summary>
/// <param name="to">The persons name to send the message to</param>
/// <param name="msg">The message to broadcast to all players</param>
public void Play(ContractTeam team, ContractPerson person, Shot shot)
{
    Console.WriteLine(string.Format("Receiving shot information from team " + 
                      "{0}, player {1}, with {2} snapshots.", 
                      team.Id, person.Name, shot.SnapshotList.Count));
    SnookerEventArgs e = new SnookerEventArgs();
    e.msgType = MessageType.ReceivePlay;
    e.team = team;
    e.person = person;
    e.shot = shot;
    BroadcastMessage(e);
    Console.WriteLine(string.Format("Shot information has been broadcasted"));
}

When receiving an incoming shot, the XNA client must reproduce exactly whatever has occurred on the other player's computer. This includes the same ball positions, the same frame rate, the same scores, the same balls potted, coherent turn shifts, and coherent game over processing.

Once the XNA client has replayed the incoming shot completely, it's crucial that both XNA clients have exactly the same state. If this consistency is not achieved, both players will have problems with disparities of ball positions, wrong turn shifts, wrong scores, and so on.

The code below illustrates the vital part of processing the incoming shot:

//Since both players receive the incoming shot
//from the WCF service, we must reproduce the shot only
//if it was made by the other player
if (contractTeam.Id != playingTeamID)
{
    if (currentIncomingShot == null)
    {
        if (incomingShotList.Count > 0)
        {
            //Since we might receive more than one incoming shot,
            //we pick only the first shot in the queue, and then
            //dequeue it.
            currentIncomingShot = incomingShotList[0];
            incomingShotList.RemoveAt(0);
        }
    }

    if (currentIncomingShot != null)
    {
        //Now we have to reproduce the other player's movement
        //snapshot-by-snapshot, exactly as it was seen by the other player.
        if (currentSnapshotNumber <= currentIncomingShot.SnapshotList.Length)
        {
            //The movement delay is also exactly the same as the other
            //player. The snapshots are processed in the same frame rate as the
            //original player.
            if (movingBallDelay <== 0)
            {
                movingBallDelay = maxMovingBallDelay;

                //Reproducing the ball positions inside the current snapshot,
                //we should check whether the ball was potted or not.
                foreach (BallPosition bp in 
                  currentIncomingShot.SnapshotList[currentSnapshotNumber].ballPositionList)
                {
                    ballSprites[bp.ballIndex].Position = new Vector2(bp.x, bp.y);
                    ballSprites[bp.ballIndex].IsBallInPocket = bp.isBallInPocket;
                }

                //The game wouldn't be that cool if your machine couldn't reproduce
                //the sound exactly as the other player's did, isn't it?
                GameSound sound = 
                  currentIncomingShot.SnapshotList[currentSnapshotNumber].sound;
                if (sound != GameSound.None)
                {
                    soundBank.PlayCue(sound.ToString());
                }
                currentSnapshotNumber++;
            }
        }
        else
        {
            //Once all ball movements have been processed,
            //the incoming scores are applied. Notice that
            //the XNA client that is playing is responsible 
            //for calculating both scores.
            //The receiving XNA client, on the other hand, just
            //accepts those scores.
            teams[0].Points = currentIncomingShot.CurrentTeamScore;
            teams[1].Points = currentIncomingShot.OtherTeamScore;

            //"GameOver" is a flag of the incoming shot data.
            //In this line, the user's machine is checking whether it
            //has lost the game
            if (currentIncomingShot.GameOver)
            {
                UpdateGameState(GameState.GameOver);
            }
            else if (currentIncomingShot.HasFinishedTurn)
            {
                //Now it's time to change turns.
                playingTeamID = (playingTeamID == 1) ? 2 : 1;
                awaitingTeamID = (playingTeamID == 1) ? 2 : 1;
                logList.Add(string.Format("Team {0} is ready to play", playingTeamID));
                logList.Add(string.Format("Team {0} is waiting", awaitingTeamID));

                //Decide which one is the next ball
                teams[playingTeamID - 1].BallOn = GetRandomRedBall();

                if (teams[playingTeamID - 1].BallOn == null)
                    teams[playingTeamID - 1].BallOn = GetMinColouredball();
            }
            currentSnapshotNumber = 0;
            currentIncomingShot = null;
        }
    }
}

5. Conclusion

If you had patience to reach this line, I'd like to thank you. Please download the application and evaluate it. And, whether you like it or not, please don't forget to leave your comments at the end of the article, I'll appreciate it. It was a lot of fun to write and to test it. I wish you lots of fun too.

History

  • 2010-02-05: First version.
  • 2010-02-06: YouTube image added.
  • 2010-02-20: Incoming/Outgoing shots - detailed and commented explanation.
  • 2010-03-06: A.I. and physics - detailed and commented explanation.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Marcelo Ricardo de Oliveira
Software Developer
Brazil Brazil
Marcelo Ricardo de Oliveira is a senior software developer who lives with his lovely wife Luciana and his little buddy and stepson Kauê in Guarulhos, Brazil, is co-founder of the Brazilian TV Guide TV Map and currently works for ILang Educação.
 
He is often working with serious, enterprise projects, although in spare time he's trying to write fun Code Project articles involving WPF, Silverlight, XNA, HTML5 canvas, Windows Phone app development, game development and music.
 
Published Windows Phone apps:
 
 
Awards:
 
CodeProject MVP 2012
CodeProject MVP 2011
 
Best Web Dev article of March 2013
Best Web Dev article of August 2012
Best Web Dev article of May 2012
Best Mobile article of January 2012
Best Mobile article of December 2011
Best Mobile article of October 2011
Best Web Dev article of September 2011
Best Web Dev article of August 2011
HTML5 / CSS3 Competition - Second Prize
Best ASP.NET article of June 2011
Best ASP.NET article of May 2011
Best ASP.NET article of April 2011
Best C# article of November 2010
Best overall article of November 2010
Best C# article of October 2010
Best C# article of September 2010
Best overall article of September 2010
Best overall article of February 2010
Best C# article of November 2009

Comments and Discussions

 
Questionthis project for unity? PinmemberNabeel Saleem2-Sep-14 12:05 
AnswerRe: this project for unity? PinmvpMarcelo Ricardo de Oliveira2-Sep-14 12:20 
GeneralMy vote of 5 PinmvpRahul Rajat Singh18-Feb-13 23:55 
GeneralMy vote of 5 PinmemberKOUKKILLER17-Sep-12 0:22 
QuestionThanks PinmemberKOUKKILLER17-Sep-12 0:21 
Questionneed help about multiplayer Pinmemberwahyutp21-Dec-11 19:34 
Generalneed help about xna4.0 PinmemberCS140113-Oct-11 18:56 
hi marcelo, i need one help from you. i installed xna 4.0 game studio. but when i run through vs10 it shows the error no suitable graphics card found. i changed game profile hidef to reach but i still receive that msg. what will i do? can u give me your email id for further contact. Smile | :)
...

GeneralThank you!!! PinmemberVlad76-Jun-11 10:54 
GeneralRe: Thank you!!! PinmvpMarcelo Ricardo de Oliveira6-Jun-11 15:08 
GeneralMy vote of 5 Pinmembermaq_rohit14-Dec-10 21:51 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira19-Dec-10 11:36 
GeneralRe: My vote of 5 Pinmembermaq_rohit20-Dec-10 0:48 
GeneralMy vote of 5 PinmemberRossouwDB7-Dec-10 18:33 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira19-Dec-10 11:34 
GeneralDefinitely a 5 Pinmemberpikey1237-Nov-10 10:13 
GeneralRe: Definitely a 5 PinmemberMarcelo Ricardo de Oliveira21-Nov-10 6:19 
GeneralMy vote of 5 Pinmemberthatraja1-Oct-10 20:56 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira4-Oct-10 12:24 
GeneralAwesome! Pinmembermanjeeet26-Jul-10 23:50 
GeneralRe: Awesome! PinmemberMarcelo Ricardo de Oliveira9-Aug-10 6:24 
GeneralOne of the best articles posted at CodeProject Pinmemberandalmeida25-Mar-10 9:14 
GeneralRe: One of the best articles posted at CodeProject PinmemberMarcelo Ricardo de Oliveira25-Mar-10 9:15 
GeneralAppcrash PinmemberPaul1n24-Mar-10 0:29 
GeneralRe: Appcrash PinmemberMarcelo Ricardo de Oliveira24-Mar-10 5:36 
GeneralRe: Appcrash PinmemberPaul1n24-Mar-10 7:33 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Mobile
Web01 | 2.8.140916.1 | Last Updated 8 Mar 2010
Article Copyright 2010 by Marcelo Ricardo de Oliveira
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid