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

C# Snooker

, 14 Dec 2009
Rate this:
Please Sign up or sign in to vote.
Sound-enabled pool game for C#.

CSharpSnooker

Introduction

This article is intended to share some nice discoveries of writing a pool game in C#. Although my first motivation is to give readers some useful programming information, I'm also hoping you really enjoy the game itself.

Background

The game is built over three cornerstones:

  • Collision detection/collision resolution: First of all, it's essential for a pool game to have a collision detection and handle it properly. When the balls are moving, they must always be confined inside the borders, and remain on the pool unless they fall into the pockets. When a ball collides with other balls or borders, you must know it, and take action to resolve the collision prior to placing the colliding ball on the screen. The collision itself is not that problematic (for example, you could just test whether two balls are closer to each other than twice their radius). The real problem is to decide where the colliding balls should be at that moment if they were real objects, and also calculate their resulting directions. I had a hard time trying to resolve collisions properly, and after giving up reinventing the wheel myself, I finally resorted to Google. Although there are many articles explaining collision resolution, I ended up using this simple and straight to the point Detection and Handling article from Matthew McDole.
  • Fast and smooth graphics rendering: At first, I tried to use a timer to control the rendering process. At every tick, the positions of the balls were calculated and then the graphics were rendered. The problem is that, usually, the calculation time was different for each tick, because sometimes I had just one ball moving, while at others, I had 10 balls colliding and moving at the same time. So the calculation effort was different. This difference affected the rendering, and appeared like it was "cut" at some points. This was frustrating. If you take, for example, other snooker games, you'll notice that each shot has a fluid rendering. Then, I refactored the code by doing all calculations first, and then created a "movie" in memory containing the sequence of frames (each frame is a snapshot of the balls at some point in time, over the pool background). After all balls stopped moving, the "movie" is done and played. At first, this looks like too much effort, but for a game like this, the speed of rendering is critical. It might have been easier if I ported the game to XNA technology, but I didn't want to force CodeProject users to download additional Visual Studio packages.
  • Realistic sound effects: When I finally got the graphics working, I noticed something was missing. I wanted the game to have sounds to make it more realistic and exciting. After some research, I found a few .wav files that could be useful for the cue hitting the cue ball, the balls hitting each other, and other real pool game sounds. Then, I tried playing it with the default System.Media.SoundPlayer object, but soon I noticed it doesn't play simultaneous sounds: whey you play a sound, all executing sounds are stopped. Fortunately, I found the wonderful IrrKlang audio engine and got the problem solved. It has a very interesting 3D audio engine, where you can define the sound and the XYZ coordinates. Just think about a first person shooter game. You are walking by a dark street, and you are hearing a soft roar coming from your right side. As you keep walking, the sound becomes louder. Walking a little more, the sound is as loud at your right ear as at your left ear. Then, the sound comes from your right side. At the end, you notice you have been followed by a treacherous monster, who was getting closer, passing from your right to your left. You can do something similar by telling the IrrKlang engine to play a "roar.wav" sound in different XYZ coordinates, considering the reference point as being the first person shooter (you). In this game, I used the 3D audio engine to play the sound according to the coordinates of the source.

The Game

Rules

The game itself is a simplified snooker game. Instead of 15 red balls, it has only 6. Each red ball grants 1 point, while the "color" balls grant from 2 to 7 points (Yellow=2, Green=3, Brown=4, Blue=5, Pink=6, Black=7).

The player must use the cue ball (white ball) to aim to pot the "ball on". The "ball on" is always alternating between a red ball and a color ball, as long as there are still red balls on the table. Once all red balls are potted, the ball on is the less valuable color ball. If the player misses the ball on, or hits another ball other than the ball on, it is a fault. If the player pots a ball other than the ball on, it is a fault. If the player fails to hit any other ball with the cue ball, it is a fault. The player will only score if there are no faults. The fault points are granted to the opponent. The game is over when all balls are potted (except for the cue ball).

int strokenBallsCount = 0;
foreach (Ball ball in strokenBalls)
{
    //causing the cue ball to first hit a ball other than the ball on
    if (strokenBallsCount == 0 && ball.Points != currentPlayer.BallOn.Points)
        currentPlayer.FoulList.Add((currentPlayer.BallOn.Points < 4 ? 4 :
                                    currentPlayer.BallOn.Points));

    strokenBallsCount++;
}

//Foul: causing the cue ball to miss all object balls
if (strokenBallsCount == 0)
    currentPlayer.FoulList.Add(4);

foreach (Ball ball in pottedBalls)
{
    //causing the cue ball to enter a pocket
    if (ball.Points == 0)
        currentPlayer.FoulList.Add(4);

    //causing a ball not on to enter a pocket
    if (ball.Points != currentPlayer.BallOn.Points)
        currentPlayer.FoulList.Add(currentPlayer.BallOn.Points < 4 ? 4 :
                                   currentPlayer.BallOn.Points);
}

if (currentPlayer.FoulList.Count == 0)
{
    foreach (Ball ball in pottedBalls)
    {
        //legally potting reds or colors
        wonPoints += ball.Points;
    }
}
else
{
    currentPlayer.FoulList.Sort();
    lostPoints = currentPlayer.FoulList[currentPlayer.FoulList.Count - 1];
}

currentPlayer.Points += wonPoints;
otherPlayer.Points += lostPoints;

User Interface

There are three important areas on the screen: the pool, the score, and the cue control.

The Pool

Figure 1. Game pool displaying its many borders in yellow.

The table is a mahogany model, covered with fine blue baize. There are six pockets, one for each corner, and two more in the middle of the long sides.

When it is your turn, when you move the mouse over the table, the mouse pointer takes the form of a target (when the ball on is already selected) or a hand (when you must select a ball on). When you hit the left mouse button, the cue ball will run from its original point to the select point.

void HitBall(int x, int y)
{
    //Reset the frames and ball positions
    ClearSequenceBackGround();
    ballPositionList.Clear();

    poolState = PoolState.Moving;
    picTable.Cursor = Cursors.WaitCursor;

    //20 is the maximum velocity
    double v = 20 * (currentPlayer.Strength / 100.0);

    //Calculates the cue angle, and the translate velocity (normal velocity)
    double dx = x - balls[0].X;
    double dy = y - balls[0].Y;
    double h = (double)(Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2)));
    double sin = dy / h;
    double cos = dx / h;
    balls[0].IsBallInPocket = false;
    balls[0].TranslateVelocity.X = v * cos;
    balls[0].TranslateVelocity.Y = v * sin;
    Vector2D normalVelocity = balls[0].TranslateVelocity.Normalize();

    //Calculates the top spin/back spin velocity,
    //in the same direction as the normal velocity, but in opposite angle
    double topBottomVelocityRatio =
        balls[0].TranslateVelocity.Lenght() * (targetVector.Y / 100.0);
    balls[0].VSpinVelocity = new Vector2D(-1.0d * topBottomVelocityRatio *
             normalVelocity.X, -1.0d * topBottomVelocityRatio * normalVelocity.Y);

    //xSound defines if the sound is coming from the left or the right
    double xSound = (float)(balls[0].Position.X - 300.0) / 300.0;
    soundTrackList[snapShotCount] = @"Sounds\Shot01.wav" + "|" + xSound.ToString();

    //Calculates the ball positions as long as there are moving balls
    while (poolState == PoolState.Moving)
        MoveBalls();

    currentPlayer.ShotCount++;
}

The Score

The score is a vintage wooden panel that shows the two players' scores. In addition, it also shows a blinking image of the ball on.

private void timerBallOn_Tick(object sender, EventArgs e)
{
    if (playerState == PlayerState.Aiming || playerState == PlayerState.Calling)
    {
        picBallOn.Top = 90 + (currentPlayer.Id - 1) * 58;
        showBallOn = !showBallOn;
        picBallOn.Visible = showBallOn;
    }
}

Figure 2. Me against the computer.

The Cue Control

The Cue Control is a brushed steel panel, and has two goals: to control the cue strength (the upper red line) and to control the cue ball "spin". You can use the strength bar to give a more precise shot according to the situation. And, the spin control is useful if you know how to do the "top spin" and the "back spin". The "top spin", also known as "follow", increases the cue ball velocity and gives a more open angle when the cue ball hits another ball. The "back spin", on the other hand, decreases the cue ball velocity, and moves back the cue ball the way it came after striking the object ball. This also affects the resulting angle after the hit, and usually makes the cue ball to move in a curve.

Notice: I didn't implement the "side spin", because I thought it would require too much effort and would add little to the article.

Figure 3. Strength control and spin control.

Figure 4. Spin paths.

Figure 5. Different spins in action: normal (no spin), back spin, and top spin.
public void ResolveCollision(Ball ball)
{
    // get the mtd
    Vector2D delta = (position.Subtract(ball.position));
    float d = delta.Lenght();
    // minimum translation distance to push balls apart after intersecting
    Vector2D mtd =
      delta.Multiply((float)(((Ball.Radius + 1.0 + Ball.Radius + 1.0) - d) / d));

    // resolve intersection --
    // inverse mass quantities
    float im1 = 1f;
    float im2 = 1f;

    // push-pull them apart based off their mass
    position = position.Add((mtd.Multiply(im1 / (im1 + im2))));
    ball.position = ball.position.Subtract(mtd.Multiply(im2 / (im1 + im2)));

    // impact speed
    Vector2D v = (this.translateVelocity.Subtract(ball.translateVelocity));
    float vn = v.Dot(mtd.Normalize());

    // sphere intersecting but moving away from each other already
    if (vn > 0.0f)
        return;

    // collision impulse
    float i = Math.Abs((float)((-(1.0f + 0.1) * vn) / (im1 + im2)));
    Vector2D impulse = mtd.Multiply(1);

    int hitSoundIntensity = (int)(Math.Abs(impulse.X) + Math.Abs(impulse.Y));

    if (hitSoundIntensity > 5)
        hitSoundIntensity = 5;

    if (hitSoundIntensity < 1)
        hitSoundIntensity = 1;

    double xSound = (float)(ball.Position.X - 300.0) / 300.0;
    observer.Hit(string.Format(@"Sounds\Hit{0}.wav",
       hitSoundIntensity.ToString("00")) + "|" + xSound.ToString());

    // change in momentum
    this.translateVelocity = this.translateVelocity.Add(impulse.Multiply(im1));
    ball.translateVelocity = ball.translateVelocity.Subtract(impulse.Multiply(im2));
}

The Movie

Figure 6. In-memory frames.

At every shot, a new "movie" is started. The application calculates all movements and makes a list of ball positions as long as there is at least one moving ball on the table. When all balls are still, the ball positions list is used to create the in-memory frames, just like the frames in a movie. When all frames are created, the movie is played, in a smooth and fast way.

void DrawSnapShots()
{
    XmlSerializer serializer = 
     new XmlSerializer(typeof(List<ballposition>));
    string path = 
     Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
    using (StreamWriter sw = new StreamWriter(Path.Combine(path,
                             @"Out\BallPositionList.xml")))
    {
        serializer.Serialize(sw, ballPositionList);
    }

    ClearSequenceBackGround();
    int snapShot = -1;

    Graphics whiteBitmapGraphics = null;

    //For each ball, draws an image of that ball 
    //over the pool background image
    foreach (BallPosition ballPosition in ballPositionList)
    {
        if (ballPosition.SnapShot != snapShot)
        {
            snapShot = ballPosition.SnapShot;
            whiteBitmapGraphics = whiteBitmapGraphicsList[snapShot];
        }

        //draws an image of a ball over the pool background image
        whiteBitmapGraphics.DrawImage(balls[ballPosition.BallIndex].Image,
          new Rectangle((int)(ballPosition.X - Ball.Radius),
          (int)(ballPosition.Y - Ball.Radius),
          (int)Ball.Radius * 2, (int)Ball.Radius * 2), 0, 0,
          (int)Ball.Radius * 2, (int)Ball.Radius * 2, GraphicsUnit.Pixel, attr);
    }
}

private void PlaySnapShot()
{
    //Plays an individual frame, by replacing the image of the picturebox with
    //the stored image of a frame
    picTable.Image = whiteBitmapList[currentSnapShot - 1]; ;
    picTable.Refresh();

    string currentSound = soundTrackList[currentSnapShot - 1];

    if (currentSound.Length > 0)
    {
        currentSound += "|0";
        string fileName = currentSound.Split('|')[0];
        Decimal x = -1 * Convert.ToDecimal(currentSound.Split('|')[1]);

        //Plays the sound considering whether the sounds comes from left or right
        soundEngine.Play3D(fileName, 0, 0, (float)x);
    }

    currentSnapShot++;
}

Sound Engine

As I mentioned previously, the game doesn't use the System.Media.SoundPlayer object to play sounds, because each new sound played "cuts" the current sound. This means, you can't hear the sound of a ball falling into a pocket and the sound of two balls colliding at the same time. I solved this with the IrrKlang component. In addition, I also tell the sound engine to play the sound according to the position of the source of the sound. For example, if a ball falls into the upper right pocket, you hear the sound louder at your right ear. If a ball hits another one at the lower corner of the table, you hear the sound coming from the left. There are some cool snooker sounds I found on the internet, and some of them are soft or hard depending on the velocity of the colliding balls:

Figure 7. Sound effects.
if (currentSound.Length > 0)
{
    currentSound += "|0";
    string fileName = currentSound.Split('|')[0];
    Decimal x = -1 * Convert.ToDecimal(currentSound.Split('|')[1]);

    //Plays the sound considering whether the sounds comes from left or right
    soundEngine.Play3D(fileName, 0, 0, (float)x);
}

A.I.

The so called "Ghost balls" play an important role in the game intelligence. When the computer plays in its turn, it is instructed to look for all good "ghost balls", so that it can have more chances of success. Ghost balls are the spots close to the "ball on", that you can aim to, so that the ball should fall into a specific pocket.

private List GetGhostBalls(Ball ballOn)
{
    List ghostBalls = new List();

    int i = 0;
    foreach (Pocket pocket in pockets)
    {
        //distances between pocket and ball on center
        double dxPocketBallOn = pocket.HotSpotX - ballOn.X;
        double dyPocketBallOn = pocket.HotSpotY - ballOn.Y;
        double hPocketBallOn = Math.Sqrt(dxPocketBallOn * 
            dxPocketBallOn + dyPocketBallOn * dyPocketBallOn);
        double a = dyPocketBallOn / dxPocketBallOn;

        //distances between ball on center and ghost ball center
        double hBallOnGhost = (Ball.Radius - 1.0) * 2.0;
        double dxBallOnGhost = hBallOnGhost * (dxPocketBallOn / hPocketBallOn);
        double dyBallOnGhost = hBallOnGhost * (dyPocketBallOn / hPocketBallOn);

        //ghost ball coordinates
        double gX = ballOn.X - dxBallOnGhost;
        double gY = ballOn.Y - dyBallOnGhost;
        double dxGhostCue = balls[0].X - gX;
        double dyGhostCue = balls[0].Y - gY;
        double hGhostCue = Math.Sqrt(dxGhostCue * dxGhostCue + dyGhostCue * dyGhostCue);

        //distances between ball on center and cue ball center
        double dxBallOnCueBall = ballOn.X - balls[0].X;
        double dyBallOnCueBall = ballOn.Y - balls[0].Y;
        double hBallOnCueBall = Math.Sqrt(dxBallOnCueBall * 
            dxBallOnCueBall + dyBallOnCueBall * dyBallOnCueBall);

        //discards difficult ghost balls
        if (Math.Sign(dxPocketBallOn) == Math.Sign(dxBallOnCueBall) && 
        Math.Sign(dyPocketBallOn) == Math.Sign(dyBallOnCueBall))
        {
            Ball ghostBall = new Ball(i.ToString(), null, 
            (int)gX, (int)gY, "", null, null, 0);
            ghostBalls.Add(ghostBall);
            i++;
        }
    }

    return ghostBalls;
}

Some ghost balls may be difficult or impossible to reach, because they lie behind the object ball. These ghost balls are to be discarded by the computer:

//discards difficult ghost balls
if (Math.Sign(dxPocketBallOn) == Math.Sign(dxBallOnCueBall) && 
    Math.Sign(dyPocketBallOn) == Math.Sign(dyBallOnCueBall))
{
    Ball ghostBall = new Ball(i.ToString(), null, (int)gX, (int)gY, "", null, null, 0);
    ghostBalls.Add(ghostBall);
    i++;
}

The computer must then choose one among the remaining ghost balls (sometimes the computer is lucky, sometimes it is not...).

private Ball GetRandomGhostBall(List ballOnList)
{
    Ball randomGhostBall = null;

    List ghostBalls = new List();

    foreach (Ball ballOn in ballOnList)
    {
        List tempGhostBalls = GetGhostBalls(ballOn);
        foreach (Ball ghostBall in tempGhostBalls)
        {
            ghostBalls.Add(ghostBall);
        }
    }

    int ghostBallCount = ghostBalls.Count;
    if (ghostBallCount > 0)
    {
        Random rnd = new Random(DateTime.Now.Second);
        int index = rnd.Next(ghostBallCount);

        randomGhostBall = ghostBalls[index];
    }
    return randomGhostBall;
}

Figure 8. Ghost Balls.

Future Releases

  • Multiplayer features.
  • Multi-machine features (to be defined: WCF, Remoting, Skype, etc.).

History

  • 2009-11-29: First version.
  • 2009-12-04: Article updated.
  • 2009-12-09: Bug fixes.
  • 2009-12-12: Improved A.I., bug fixes.

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

 
GeneralMy vote of 5 Pinmemberxisouthros11-Nov-11 20:51 
GeneralDoes not run under 64-bit Windows 7. PinmemberJan Lund18-Dec-10 9:51 
GeneralCrashes when "zero" speed selected PinmemberRossouwDB6-Dec-10 2:46 
GeneralRe: Crashes when "zero" speed selected PinmemberMarcelo Ricardo de Oliveira7-Dec-10 4:39 
GeneralMy vote of 5 Pinmemberzooley6-Nov-10 0:08 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira3-Dec-10 7:53 
GeneralMy vote of 5 PinmemberJFergulbops31-Oct-10 23:35 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira3-Dec-10 7:53 
Generalhi marcelo your code is too good Pinmemberkvenkatesh00715-Oct-10 20:43 
GeneralRe: hi marcelo your code is too good PinmemberMarcelo Ricardo de Oliveira27-Oct-10 3:27 
GeneralGreat solution PinmemberMiguel Angel Medina Pérez8-Oct-10 19:11 
GeneralRe: Great solution PinmemberMarcelo Ricardo de Oliveira9-Oct-10 3:39 
GeneralRe: Great solution PinmemberMarcelo Ricardo de Oliveira9-Oct-10 3:51 
GeneralMy vote of 5 PinmemberSyed Shabeer29-Sep-10 1:28 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira6-Oct-10 9:28 
GeneralMy vote of 5 PinmemberMoim Hossain24-Sep-10 1:35 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira28-Sep-10 1:57 
GeneralMy vote of 5 PinmemberShamim.emon0820-Sep-10 8:32 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira22-Sep-10 6:02 
GeneralMy vote of 5 Pinmemberthatraja2-Sep-10 4:03 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira22-Sep-10 6:02 
GeneralMy vote of 5 Pinmemberravi_tech16-Aug-10 11:15 
GeneralRe: My vote of 5 PinmemberMarcelo Ricardo de Oliveira20-Aug-10 6:08 
GeneralNice one champ! Pinmembermanjeeet26-Jul-10 23:47 
GeneralRe: Nice one champ! PinmemberMarcelo Ricardo de Oliveira9-Aug-10 6:22 

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
Web03 | 2.8.140916.1 | Last Updated 14 Dec 2009
Article Copyright 2009 by Marcelo Ricardo de Oliveira
Everything else Copyright © CodeProject, 1999-2014
Terms of Service
Layout: fixed | fluid