Click here to Skip to main content
15,880,967 members
Articles / Game Development

A Pong in Silverlight

Rate me:
Please Sign up or sign in to vote.
0.00/5 (No votes)
31 Aug 2011CPOL8 min read 23.3K   698   10   1
A tutorial on how to write a Silverlight game based on the classical Pong game.

Silverlight_Pong.jpg

Introduction

This article explains how to write a game using Silverlight. It explains the mechanisms needed to make a game, how to implement them, and shows them applied to a complete game.

The game is as simple as possible (but fully implemented). This allows us to go through the full code and to understand how it works. I deliberately did not write any sprite framework or helper functions so that the article can focus on how the code accesses the Silverlight framework and the game basic mechanism.

The objective is that after having read this article, you would know:

  1. What is needed to make a game
  2. What functionalities of Silverlight you may call to implement this game

Background

The original idea behind this article was to see if using Silverlight I could write quickly a simple game like I used to do in the 80's on my TI99/4A or on my BBC model B. In the 80's, it was possible in a few hours and a few lines of code to write simple games like a break out, a Pac man, or a Tetris.

I decided to write the simplest possible game: just a paddle and a ball, and tried to write the code in the 80's style (i.e., without object oriented coding).

The conclusion for this experiment is that indeed Silverlight offers a platform that allows to write games quite easily. The full game source code is less than 200 lines of C# and 50 lines of XAML.

Using the Code

This is the full code. It is not that long for a full game, is it?

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;

namespace SpriteDemo1
{
    public partial class MainPage : UserControl
    {
        enum GameState { RUNNING, PAUSED, GAMEOVER };
        private double ballPosX;
        private double ballSpeedX;
        private double ballPosY;
        private double ballSpeedY;
        private double paddlePosX;
        private DateTime PreviousTime;
        private double paddleSpeed;
        private GameState gameState;
        private int score;
        private int HiScore;
        private int Life;
        private Random rnd = new Random();
        public MainPage()
        {
            InitializeComponent();
            HiScore = 0;

            this.textBlockGameStatus.Text = 
                  "Please Resize this Window to a pleasant size\n"+
                  "Then Click on the game area to Start Game";
            gameState = GameState.GAMEOVER;
            CompositionTarget.Rendering += newFrame;
        }


        private void NewGame()
        {
            score = 0;
            this.textBlockScore.Text = "Score: " + score.ToString();
            this.textBlockHiScore.Text = "Hi Score: " + HiScore.ToString();
            Life = 3;
            NewLife();
        }

        private void NewLife()
        {
            const double START_MARGIN = 10;

            Life--;
            this.textBlockLife.Text = Life.ToString() + " Balls Left";

            ballPosX = START_MARGIN + rnd.NextDouble() * 
                      (this.GameCanvas.ActualWidth - 2 * START_MARGIN);
            ballSpeedX = 0.3;
            ballPosY = this.GameCanvas.ActualHeight / 2 - this.Ball.ActualHeight;
            ballSpeedY = -0.3;

            paddleSpeed = 0;
            paddlePosX = (this.GameCanvas.ActualWidth - this.Paddle.ActualWidth) / 2;

            PreviousTime = DateTime.Now;

            this.Paddle.Visibility = System.Windows.Visibility.Visible;
            this.Ball.Visibility = System.Windows.Visibility.Visible;

            gameState = GameState.PAUSED;
            this.textBlockGameStatus.Text = "Ready";
        }


        protected void newFrame(object sender, EventArgs e)
        {
            DateTime now;
            double ellapsedms;

            now = DateTime.Now;
            ellapsedms = (now - PreviousTime).Milliseconds;

            //this.textBlockHiScore.Text = 
            //   (1000 / ellapsedms).ToString() + " fps";
            PreviousTime = now;
            if (gameState == GameState.RUNNING)
            {
                if ((ballSpeedX > 0 && ballPosX + 
                     Ball.ActualWidth > this.GameCanvas.ActualWidth)
                    || (ballSpeedX < 0 && ballPosX < 1))
                {
                    ballSpeedX = -ballSpeedX;
                    this.BeepX.Position = TimeSpan.Zero;
                    this.BeepX.Play();
                }
                ballPosX += ballSpeedX * ellapsedms;

                if (ballSpeedY > 0 
                   && ballPosY + Ball.ActualHeight >
                       this.GameCanvas.ActualHeight - this.Paddle.ActualHeight)
                {
                    if (ballPosX + this.Ball.ActualWidth > paddlePosX 
                        && ballPosX < paddlePosX + this.Paddle.ActualWidth)
                    {
                        this.BeepPaddle.Position = TimeSpan.Zero;
                        this.BeepPaddle.Play();
                        ballSpeedY = -ballSpeedY - 0.05;
                        ballSpeedX += paddleSpeed;
                        score++;
                        this.textBlockScore.Text = "Score: " + score.ToString();
                    }
                    else
                    {
                        gameState = GameState.GAMEOVER;
                        this.Paddle.Visibility = System.Windows.Visibility.Collapsed;
                        this.Ball.Visibility = System.Windows.Visibility.Collapsed;
                        if (Life > 0)
                        {
                            this.BeepLost.Position = TimeSpan.Zero;
                            this.BeepLost.Play();
                            this.textBlockGameStatus.Text = "Click or Press Space";
                        }
                        else
                        {
                            this.BeepGameOver.Position = TimeSpan.Zero;
                            this.BeepGameOver.Play();
                            if (score > HiScore)
                            {
                                HiScore = score;
                                this.textBlockHiScore.Text = "Hi Score: " + HiScore.ToString();
                            }
                            this.textBlockGameStatus.Text = 
                               "Game Over\nClick or Press Space To Start a New Game";
                        }
                    }
                }
                if (ballSpeedY < 0 && ballPosY < 1)
                {
                    ballSpeedY = -ballSpeedY;
                    this.BeepTop.Position = TimeSpan.Zero;
                    this.BeepTop.Play();
                }
                ballPosY += ballSpeedY * ellapsedms;

                // move the paddle
                if ((paddleSpeed > 0 && paddlePosX + 
                     this.Paddle.ActualWidth < this.GameCanvas.ActualWidth)
                 || (paddleSpeed < 0 && paddlePosX > 1))
                { paddlePosX += paddleSpeed * ellapsedms; }
            }
            //display the sprites at their correct position
            this.Ball.SetValue(Canvas.TopProperty, ballPosY);
            this.Ball.SetValue(Canvas.LeftProperty, ballPosX);

            this.Paddle.SetValue(Canvas.TopProperty,
                       this.GameCanvas.ActualHeight - this.Paddle.ActualHeight);
            this.Paddle.SetValue(Canvas.LeftProperty, paddlePosX);
        }

        private void LayoutRoot_KeyDown(object sender, KeyEventArgs e)
        {
            if (gameState == GameState.PAUSED && 
                      (e.Key == Key.Left || e.Key == Key.Right))
            {
                gameState = GameState.RUNNING;
                this.textBlockGameStatus.Text = "";
            }
            if (e.Key == Key.Left) { paddleSpeed = -0.5; }
            if (e.Key == Key.Right) { paddleSpeed = +0.5; }
        }

        private void LayoutRoot_KeyUp(object sender, KeyEventArgs e)
        {
            if (e.Key == Key.Left || e.Key == Key.Right) { paddleSpeed = 0; }
        }

        private void button1_LostFocus(object sender, RoutedEventArgs e)
        {
            if (gameState == GameState.RUNNING)
            {
                gameState = GameState.PAUSED;
                this.textBlockGameStatus.Text = "Please Click on The Game";
            }
        }

        private void button1_GotFocus(object sender, RoutedEventArgs e)
        {
            if (gameState == GameState.PAUSED)
            { this.textBlockGameStatus.Text = "Game Paused"; }
        }

        private void button1_Click(object sender, RoutedEventArgs e)
        {
            if (gameState == GameState.GAMEOVER)
            {
                if (Life > 0)
                { NewLife(); }
                else
                { NewGame(); }
            }
        }
    }
}

Points of Interest

The Game Loop

For a game animation, you must constantly redraw the play area to update the position of each moving item (known as sprite). To have smooth movements, the redraw must be fast enough, typically 50 or 60 times per second. Each newly generated image is called a frame so we must generate 50 or 60 frames per second (i.e., 50 or 60 fps).

Silverlight 3 offers a great way to trigger repetitive frame generation:

C#
CompositionTarget.Rendering+= newFrame 

This ensures that the newFrame function is called 60 times per second.

The function newFrame (you may give any name you want to it) must have the following definition:

C#
void newFrame(object sender, EventArgs e)

The Game Layout

The initial idea was to use a Canvas for the full application and to draw shapes on it for the sprites. But this is not enough: we need an item to receive the focus and detect keyboard input, and neither the Canvas nor the Shape is appropriate. That's why I added a button. Ideally, this button should cover the full game area so that if the user clicks the game area, he/she gives the focus to the button.

Since the Canvas does not resize the children it contains, I was forced to add a grid and to put the canvas and the (completely transparent) button in the grid cell delimiting the game area. The button and the canvas are configured to stretch over the complete grid cell.

To display messages (like "game over") centered on the game area, I added a TextBlock between the Canvas and the transparent button. I also use the TextBlock to display the score and life. But since they are not part of the game area, I put them on another grid row.

In the end, we get the following structure:

Layout.png

The Game State Machine

A state machine is associated with a game. The current state is tracked by the variable gameState. The possible states are:

  1. GameState.GAMEOVER: This state indicates that no ball is visible on the screen and that a call to NewGame() or NewLife() is needed to assign a new start position to the ball. This state is reached when the ball misses the paddle. It is also the initial state of the game.
  2. GameState.PAUSED: This state indicates that the ball has a correct position and is visible but all the sprite animations are blocked. This state occurs after the initial setting of the ball position (i.e., after a call to NewLife()) or when the game loses the focus. Indeed it would be annoying that the ball continues to move while the player cannot move the paddle anymore because another window has got the focus.
  3. GameState.RUNNING: This is the state where the animation is moving the sprites around the screen. This state occurs when the player starts (or restarts) the game by pressing a left or right arrow key.

StateMachine.png

The Keyboard and Mouse Handling

The keyboard and mouse handling relies on five event handlers:

  • LayoutRoot_KeyDown(object sender, KeyEventArgs e): This event occurs when the user presses a key (it bubbles up from the button); if the key is the left or right arrow, the paddle speed (i.e., the variable paddleSpeed) is set to a value positive or negative depending on the selected key. This event handler is also responsible for the transition from the state PAUSED to the the state RUNNING.
  • LayoutRoot_KeyUp(object sender, KeyEventArgs e): This event occurs when the player releases the key. It allows to stop the paddle movement by setting the variable paddleSpeed to zero.
  • button1_LostFocus(object sender, RoutedEventArgs e): This event handler detects the loss of focus of the button that covers the full game area. Since this button is the only element that can get the focus in this application, losing this focus means that the application becomes unable to handle the keyboard and the state must become PAUSED.
  • button1_GotFocus(object sender, RoutedEventArgs e): This event handler detects when the focus comes back. This does not change the game state (a key down event is needed for that) so it is only used to update the message at the center of the game area.
  • button1_Click(object sender, RoutedEventArgs e): This last event handler detects when the transparent button that covers the full game area is clicked (since it always has the focus, this can either come from a mouse click or the button default key: the space key). This handler is used to detect when the user requests to launch a new ball.

The Frames Generation and the Sprite Movements

The function newFrame is called 60 times per second by the game loop. It must perform the following operations:

  1. Determine how much time elapsed since the last call to newFrame.
  2. Compute the new position of each sprite (in the present game, we only have two sprites: the paddle and the ball, but in a more complex game like Space Invaders, you could have many sprites).
  3. Determine the collision and update the score and game state if needed.
  4. Display the sprites at their correct location.

1. Determine the Elapsed Time

In the early days, the CPU was running at 4.77MHz on every PC, and games written at that time were expecting that CPU clockspeed. However, those games became unplayable when the CPU speed was upgraded to 12MHz (or more) which meant that the speed of all the sprites increased. To avoid this problem, we need to know precisely how much time did elapse since the last call to newFrame and use this elapsed time to determine how far each sprite must move. We keep the timestamp of the last call in the variable private DateTime PreviousTime; and use the following code to measure the elapsed time:

C#
protected void newFrame(object sender, EventArgs e)
{
   DateTime now;
   double ellapsedms;
   now = DateTime.Now;
   ellapsedms = (now - PreviousTime).Milliseconds;
   PreviousTime = now;

   ...

2. Compute the New Sprite Position

Sprites are associated with 4 variables: an X position, a Y position, an X speed, and a Y speed.

(Note: the paddle has no Y position and no Y speed since it is always located at the bottom of the screen.)

C#
private double ballPosX;
private double ballSpeedX;
private double ballPosY;
private double ballSpeedY;
private double paddlePosX;
private double paddleSpeed;

To move the sprite, we add the speed multiplied by the elapsed time to the position:

C#
ballPosX += ballSpeedX * ellapsedms;
ballPosY += ballSpeedY * ellapsedms;
paddlePosX += paddleSpeed * ellapsedms;

3. Determine the Collision and Update the Score and Game State if Needed

In the case of a collision of the ball with a wall or with the paddle, we invert the speed.

The following example shows the case of a collision with a side wall:

C#
if ((ballSpeedX > 0 && ballPosX + Ball.ActualWidth > 
     this.GameCanvas.ActualWidth) || (ballSpeedX < 0 && ballPosX < 1))
{
    ballSpeedX = -ballSpeedX;
    this.BeepX.Position = TimeSpan.Zero;
    this.BeepX.Play();
}

Note the sound generation associated with the collision of the ball on the wall.

The score and game status in the newFrame function are updated when the ball goes downwards at the bottom of the game area:

C#
if (ballSpeedY > 0 && ballPosY + Ball.ActualHeight > 
    this.GameCanvas.ActualHeight - this.Paddle.ActualHeight)
{
...

If the ball hits the paddle:

C#
if (ballPosX + this.Ball.ActualWidth > paddlePosX 
        && ballPosX < paddlePosX + this.Paddle.ActualWidth)

we play a sound, change the ball speed, and increase the core.

C#
this.BeepPaddle.Position = TimeSpan.Zero;
this.BeepPaddle.Play();
ballSpeedY = -ballSpeedY - 0.05;
ballSpeedX += paddleSpeed;
score++;
this.textBlockScore.Text = "Score: " + score.ToString();

Otherwise the paddle missed the ball, so we go to the "GAMEOVER" state and we hide the ball and the paddle:

C#
else
{
    gameState = GameState.GAMEOVER;
    this.Paddle.Visibility = System.Windows.Visibility.Collapsed;
    this.Ball.Visibility = System.Windows.Visibility.Collapsed;
    ...

4. Display the Sprites at Their Correct Location

To set the sprite position within the canvas, we set the properties Top and Left of each shape to the correct (newly computed) values.

C#
this.Ball.SetValue(Canvas.TopProperty, ballPosY);
this.Ball.SetValue(Canvas.LeftProperty, ballPosX);

this.Paddle.SetValue(Canvas.TopProperty, 
     this.GameCanvas.ActualHeight - this.Paddle.ActualHeight);
this.Paddle.SetValue(Canvas.LeftProperty, paddlePosX);

The Sounds

Silverlight 3 offers a simple way to interactively play MP3 sounds.

  1. Put the .mp3 file as a resource of your Silverlight application.
  2. Declare a MediaElement in XAML for each .mp3 file.
  3. XML
    <MediaElement x:Name="BeepGameOver" Source="GameOver.mp3" AutoPlay="False" />
  4. When you need to play the sound, use the following code:
  5. C#
    this.BeepGameOver.Position = TimeSpan.Zero;
    this.BeepGameOver.Play();

When the ball hits the wall near the corner, there is a possibility that the ball will hit the side wall and the top wall nearly (but not exactly) at the same time. This means that we must generate the "beep" for the top wall before the end of the "beep" for the side wall. If we use the same MediaElement for both, the first "beep" will be interrupted by the second one. This is not what we want. To avoid this, we create in XAML two MediaElements for the same MP3 file: one for the side wall and one for the top wall so that playing the top wall "beep" does not stop the side wall one.

XML
<MediaElement x:Name="BeepX" Source="beep1.mp3" AutoPlay="False" />
<MediaElement x:Name="BeepTop" Source="beep1.mp3" AutoPlay="False" />

History

  • Aug 31 2011: First version.

License

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


Written By
Team Leader
Belgium Belgium
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
NewsLink to playable Game Pin
pierre poliakoff31-Aug-11 10:57
pierre poliakoff31-Aug-11 10:57 

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

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