Inside F#

Brian's thoughts on F# and .NET

Game programming in F# (with Silverlight and WPF)

Posted by Brian on April 24, 2010

Shall we play a game?

I wanted to learn more about Silverlight, WPF, and Xaml (in the context of F#, of course), so I wrote a fun little game you can play in your browser (or on your desktop).  It’s a knock-off of a dozen other similar games, as you can probably tell from this screenshot:

SuperBreakAwayScreenshot

What you don’t get from the screenshot are the amusing sound effects or the fun game play, so why not go try it out in your browser right now?  I’ll still be here when you return. (Don’t forget to come back and keep reading!)

<waits patiently for your return>

It’s kinda fun, right?  I was pleased with it, especially given that it is just the second Silverlight app I ever wrote (you might recall the first, here), and it’s also my first real foray into interactive game programming.

How I wrote it

I really had little clue what I was doing.

I had some pictures sketched out on paper of what I wanted the app to look like.  But I have little experience with WPF, and so I spent a bit of time looking up APIs for things like how to draw a circle on the screen, or a line.  The basic math for keeping track of ball velocities and bouncing off walls is pretty straightforward, so I coded that all up.  I had no idea how it would perform in real time, so I did the simplest thing – I wrote a game loop that just updates the state of the world every 20 milliseconds.  Of course I used F# async to simplify authoring the UI logic and reacting to keyboard/mouse events.  But I kept it as simple as possible; all the code runs on the UI thread.

By the end of an evening I had the basic game app working, which amazed me, as I had no idea how long it might take.  Despite blundering through new-to-me Silverlight and WPF APIs and having to read lots of docs, I had a playable game in less than six hours of coding.

It was running a little slow, so the next time I sat down with it, I added some “#if SILVERLIGHT” bits to the code so that I compile it as either a desktop app or a Silverlight app.  Maybe there is a way to profile Silverlight code, I dunno, but I do already know how to use Visual Studio’s profiler on desktop apps.  So I put the desktop version through the profiler, found one place where I was hemorrhaging memory (creating lots of extra work for the garbage collector), fixed that (with one line of code), and then the app was pretty snappy.  This pleased me to no end; you can have 3200 WPF objects moving around on the screen in a simple game loop, and it still runs smoothly, even in Silverlight, in the browser, on a laptop.  I’d really thought I was going to have to do a lot of work to get good runtime performance out of the game I had envisioned, but in fact I just did the most straightforward thing and the perf was already good enough.  WPF and Silverlight do not suck!

Since then I’ve done a little more work: some refactoring and cleanup, as well as adding the sound effects (I searched the web for public-domain sounds and picked four that I liked).  I also added the startup screen and the pause button.  There is a lot more I might like to do with the app (add scoring, different levels, different fun aspects of the game play), but my teammates on the F# team have pointed out that it’s already a nice “F# sample app” and I should just go ahead and publish it already.  So here we are.

Did I mention it’s only 400 lines of code?

It’s only 400 lines of code.  And it’s pure F#.  Actually, those are both lies; there are 373 lines of F# code, and 20 lines of Xaml.  So it’s less than 400 lines total, and not quite pure F# because I wanted to try out Xaml and so I coded a little in Xaml to learn a little more about that.

The source code

The full source code is below, for those who just want to read it here in the blog post.  I also zipped up the Visual Studio 2010 solution, which you can download from this page.  The solution contains two projects, the F# WPF app and the F# Silverlight app, that compile from the same source code.  (Note that if you try to run the Silverlight project using ‘F5’ in Visual Studio it will not work; it will open the browser to the ‘bin’ directory, rather than ‘bin\Debug’ or ‘bin\Release’, due to a product bug.  But you can just build the project, and then manually open the generated test page HTML in your browser.  Also, sometimes when I open the Xaml file in the designer, it gives me an error, but then if I reload the file again, it works.  (There is a reason we did not ship an F# “Silverlight Application” template in the box – we still have a number of little tooling bugs we need to fix.)  If you want to do things on your own, I’d recommend creating a “C# Silverlight Application” project that uses an “F# Silverlight Library” project, for a smoother tooling experience inside Visual Studio.  But I was stubborn and wanted only F#, so I did this.  Anyway…)

Here’s the little bit of Xaml:

<StackPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
            Name="mainPanel">
    <Popup Name="popup">
        <Canvas Name="popupCanvas">
            <TextBox Name="popupTop" FontSize="18" Canvas.Left="20" Canvas.Top="20" />
            <TextBox Name="popupMiddle" FontSize="14" Canvas.Left="20" Canvas.Top="60" />
            <TextBox Name="popupBottom" FontSize="18" Canvas.Left="20" Canvas.Top="200" />
        </Canvas>
    </Popup>
    <Border BorderThickness="15.0" BorderBrush="Black">
    <StackPanel Name="stackPanel1">
        <TextBlock Text="Super BreakAway!" FontSize="24" HorizontalAlignment="Center" />
        <TextBlock Text="written in F#, by Brian McNamara - press 'p' to pause" FontSize="12" HorizontalAlignment="Center" />
        <Border BorderThickness="2.0" BorderBrush="Black">
            <Canvas Name="canvas" Background="White" />
        </Border>
    </StackPanel>
</Border>
</StackPanel>

and here’s the F# code:

namespace Module1

open System.Windows 
open System.Windows.Shapes 
open System.Windows.Controls  
open System.Windows.Controls.Primitives 
open System.Windows.Media 

module WPFExtensions =
    module Canvas =
        let SetTopLeft(element, top, left) =
            Canvas.SetTop(element, top)
            Canvas.SetLeft(element, left)
    type System.Windows.Controls.Canvas with
        member this.AddAt(top, left, element) =
            Canvas.SetTopLeft(element,top,left)
            this.Children.Add(element) |> ignore
    let WHITE = new SolidColorBrush(Colors.White)
    let GRAY = new SolidColorBrush(Colors.Gray)
    let BLACK = new SolidColorBrush(Colors.Black)
open WPFExtensions

module GLOBAL_CONFIGURABLE_CONSTANTS =
    let E = 0.0001
    // pixel size of a ball/brick
    let SIZE = 6.0
    // initial grid size of blocks
    let WIDTH = 80
    let HEIGHT = 20
    // paddle size
    let PADHEIGHT = 10.0
    let PADWIDTH = 8.0 * SIZE
    let CHEAT = false
open GLOBAL_CONFIGURABLE_CONSTANTS

module GLOBAL_COMPUTED_CONSTANTS =
    let HALFSIZE = SIZE / 2.0
    // pixel location of bottom of bricks
    let BOTBRICKS = float HEIGHT * SIZE
    // canvas size
    let CANWIDTH=SIZE * float WIDTH
    let CANHEIGHT=SIZE * 80.0
    // pixel location of top of paddle
    let TOPPAD = CANHEIGHT-60.0
    let HALFPADWIDTH = PADWIDTH / 2.0
open GLOBAL_COMPUTED_CONSTANTS

module GLOBALS =
    let loadXaml<'T when 'T :> FrameworkElement>(xamlPath) =  
        use stream = System.Reflection.Assembly.GetExecutingAssembly().GetManifestResourceStream(xamlPath) // if BuildAction=EmbeddedResource
#if SILVERLIGHT
        let stream = (new System.IO.StreamReader(stream)).ReadToEnd()
#endif
        let xaml = System.Windows.Markup.XamlReader.Load(stream) 
        let uiObj = xaml :?> 'T
        uiObj

    let (?) (fe:FrameworkElement) name : 'T =
        fe.FindName(name) :?> 'T
       
    let mainPanel : StackPanel = loadXaml("MainWindow.xaml")
    let canvas : Canvas = mainPanel?canvas
    let popup : Popup = mainPanel?popup
    let popupCanvas : Canvas = mainPanel?popupCanvas
    let popupTop : TextBox = mainPanel?popupTop
    let popupMiddle : TextBox = mainPanel?popupMiddle
    let popupBottom : TextBox = mainPanel?popupBottom
    // Note that making these module-scoped globals gets rid of lots of the codegen-closure-costs of referencing them from inside an async.
    // I have not profiled, but looking at the codegen in Reflector, all the little closure classes are much smaller when these guys are globals.
    // So I am presuming this may help perf.  (But then I tried profiling, and changing it back does not seem to matter much.)
    let RNG = new System.Random()
    // main data objects
    let mutable remaining = WIDTH * HEIGHT
    let mutable active = 1
    let mutable wantPaddleBeep = false
    let mutable wantBlockBeep = false
    let mutable firstTime = true
    // main UI objects
    let tb = new TextBlock(Height=25.0, Width=CANWIDTH, Text="", FontSize=20.0)
    let debug = new TextBlock(Height=25.0, Width=CANWIDTH, Text="", FontSize=10.0)
    let paddle = new Rectangle(Width=PADWIDTH, Height=PADHEIGHT, Fill=BLACK)
    /// as n varies from 0-max-1, this makes a pretty color spectrum
    let makeColor(n,max) =
        if n < 1*max/4 then
            let px = (n-0*max/4)*256*4/max
            new SolidColorBrush(Color.FromArgb(0xFFuy,0xFFuy,byte(px),0uy))
        elif n < 2*max/4 then
            let px = (n-1*max/4)*256*4/max
            new SolidColorBrush(Color.FromArgb(0xFFuy,0xFFuy-byte(px),0xFFuy,0uy))
        elif n < 3*max/4 then
            let px = (n-2*max/4)*256*4/max
            new SolidColorBrush(Color.FromArgb(0xFFuy,0uy,0xFFuy-byte(px),byte(px)))
        else 
            let px = (n-3*max/4)*256*4/max
            new SolidColorBrush(Color.FromArgb(0xFFuy,byte(px),0uy,0xFFuy))

    let NUMBEEP = 20
#if SILVERLIGHT
    let makeMedia(file) = new MediaElement(Source = new System.Uri(file, System.UriKind.Relative), AutoPlay = false)
#else
    let makeMedia(file) = new MediaElement(Source = new System.Uri(file, System.UriKind.Relative), LoadedBehavior = MediaState.Manual)
#endif

    let attachMedia(file) =
        let sound = makeMedia(file)
        sound.MediaFailed.Add (fun ea -> debug.Text <- ea.ErrorException.ToString())
        canvas.Children.Add(sound) |> ignore
        sound

    let beeps = Array.init NUMBEEP (fun _ -> attachMedia("BEEPPURE.wma"))
    let mutable curBeep = 0
    let blockBeeps = Array.init NUMBEEP (fun _ -> attachMedia("BEEPDOUB.wma"))
    let winSound = attachMedia("happykids.wma")
    let loseSound = attachMedia("boooo.wma")
    let playOnce(sound : MediaElement) =
        async { sound.Play()
                let! _ = Async.AwaitEvent sound.MediaEnded 
                sound.Stop() } |> Async.StartImmediate 
    // useful functions
    let Assert(b) =
        assert(b)
        //if not b then raise <| new System.Exception("assert failed")

    // screen coordinates, a ball hit a block (filling space [0-SIZE,0-SIZE]) at point
    // (x,y) with velocity (dx,dy) - did it hit the side of the brick (as opposed to top/bottom)?
    let hitSide(x,y,dx,dy) =
        let ballSlope = -dy/dx
        if dy>0.0 then 
            if dx<0.0 then
                // it's going 'down-left'
                let s = y/(SIZE-x)
                ballSlope < s
            else
                // it's going 'down-right'
                let s = -y/x
                ballSlope > s
        else
            if dx>0.0 then
                // it's going 'up-right'
                let s = (SIZE-y)/x
                ballSlope < s
            else
                // it's going 'up-left'
                let s = -(SIZE-y)/(SIZE-x)
                ballSlope > s
    let _ok =
        Assert(hitSide(HALFSIZE,HALFSIZE,10.0,1.0))             // -
        Assert(hitSide(HALFSIZE,HALFSIZE,10.0,-1.0))            // -
        Assert(not<|hitSide(HALFSIZE,HALFSIZE,1.0,-10.0))       // |
        Assert(not<|hitSide(HALFSIZE,HALFSIZE,-1.0,-10.0))      // |
        Assert(hitSide(HALFSIZE,HALFSIZE,-10.0,-1.0))           // -
        Assert(hitSide(HALFSIZE,HALFSIZE,-10.0,1.0))            // -
        Assert(not<|hitSide(HALFSIZE,HALFSIZE,-1.0,10.0))       // |
        Assert(not<|hitSide(HALFSIZE,HALFSIZE,1.0,10.0))        // |

    let ensureNonZero x = if x=0.0 then E else x
open GLOBALS

[<RequireQualifiedAccess>]
type BlockState =
    | InitialPosition       // in block rows at top
    | Active                // a ball, moving around
    | Removed               // fell off bottom

type Block(shape : Ellipse) =
    let mutable state = BlockState.InitialPosition
    // next 3 fields only matter when state=Active
    let mutable xSpeed = 0.0
    let mutable ySpeed = 0.0
    let mutable tail : Line = null
    do Assert(canvas.Children.Contains(shape)) 
    member this.State = state
    member this.Shape = shape
    member this.Reflect() =
        ySpeed <-  -abs(ySpeed)
    member this.Remove() =
        Assert(state = BlockState.Active)
        canvas.Children.Remove(shape) |> ignore
        canvas.Children.Remove(tail) |> ignore
        state <- BlockState.Removed 
    member this.BreakAway() =
        Assert(state = BlockState.InitialPosition)
        xSpeed <- ensureNonZero(SIZE * (RNG.NextDouble() - 0.5))
        ySpeed <- SIZE * (RNG.NextDouble() + 2.0)/3.1  // trying to ensure ySpeed < SIZE, so ball never goes completely through a row undetected in a single 'step'
        Canvas.SetTop(shape, Canvas.GetTop(shape)+SIZE*1.5)
        tail <- new Line(X1=Canvas.GetLeft(shape), X2=Canvas.GetLeft(shape), 
                         Y1=Canvas.GetTop(shape), Y2=Canvas.GetTop(shape), 
                         StrokeThickness=SIZE/3.0, Stroke=GRAY)
        canvas.Children.Add(tail) |> ignore
        state <- BlockState.Active 
    member this.MoveOneStep() =
        Assert(state = BlockState.Active)
        let origCenteredX = Canvas.GetLeft(shape) + HALFSIZE
        let origCenteredY = Canvas.GetTop(shape) + HALFSIZE
        // compute new X
        let newX = xSpeed + Canvas.GetLeft(shape)
        let flipX(r) = xSpeed <-  -xSpeed; r
        let newX = if newX < 0.0 then flipX 0.0 else newX
        let newX = if newX > CANWIDTH-E then flipX(CANWIDTH-E) else newX
        // compute new Y
        let newY = ySpeed + Canvas.GetTop(shape)
        let flipY(r) = ySpeed <-  -ySpeed; r
        let newY = if newY < 0.0 then flipY 0.0 else newY
        // update position
        Canvas.SetTopLeft(shape, newY, newX)
        // update trailer line
        let newCenteredX = Canvas.GetLeft(shape) + HALFSIZE
        let newCenteredY = Canvas.GetTop(shape) + HALFSIZE
        tail.X2 <- newCenteredX
        tail.Y2 <- newCenteredY
        tail.X1 <- 4.0 * (origCenteredX - newCenteredX) + newCenteredX
        tail.Y1 <- 4.0 * (origCenteredY - newCenteredY) + newCenteredY
    member this.HitPaddle(dx) =
        Assert(state = BlockState.Active)
        ySpeed  <-  -abs(ySpeed)
        xSpeed <- ensureNonZero(xSpeed + dx)
    member this.ReboundOffBrick(dLeft, dTop) =
        let side = hitSide(dLeft,dTop,xSpeed,ySpeed)
        if side then 
            xSpeed <-  -xSpeed
        else
            ySpeed <-  -ySpeed

type MyApp() as this =
#if SILVERLIGHT
    inherit Application()
#else
    inherit Window()
#endif
    let cc = new ContentControl()
    let blocks = Array2D.init HEIGHT WIDTH (fun y x -> 
        let e = new Ellipse(Width=SIZE, Height=SIZE, Fill=makeColor(x,WIDTH))
        canvas.AddAt(SIZE * float y, SIZE * float x, e)
        new Block(e))

    do
        canvas.Width <- CANWIDTH; canvas.Height <- CANHEIGHT 
        canvas.AddAt(TOPPAD, CANWIDTH / 2.0, paddle)
        canvas.AddAt(TOPPAD+PADHEIGHT+5.0, 10.0, tb)
        canvas.AddAt(TOPPAD+PADHEIGHT+30.0, 10.0, debug)

        popupCanvas.Background <- new SolidColorBrush(Color.FromArgb(0xFFuy,0uy,0uy,0xFFuy), Opacity=0.6)
        popup.HorizontalAlignment <- HorizontalAlignment.Left 
        popup.VerticalAlignment <- VerticalAlignment.Top 
#if SILVERLIGHT
        // Silverlight popups are relative to the whole control
#else
        // WPF popups have more control
        popup.Placement <- PlacementMode.Relative 
        popup.PlacementTarget <- mainPanel
        popup.HorizontalOffset <- 0.0
        popup.VerticalOffset <- 0.0
#endif

        blocks.[HEIGHT-1,WIDTH/2].BreakAway()
        remaining <- remaining - 1
        tb.Text <- sprintf "%d bricks remain, %d balls active" remaining active

#if SILVERLIGHT
        this.UnhandledException.Add(fun ea -> debug.Text <- ea.ExceptionObject.ToString())
        this.Startup.Add(fun _ ->
#else
        this.Loaded.Add(fun _ ->
#endif
            async {
                do! Async.Sleep(50)  // a hack, need to wait until ActualHeight is populated
                popupCanvas.Height <- mainPanel.ActualHeight 
                popupCanvas.Width <- mainPanel.ActualWidth 
                popup.IsOpen <- true
                popupTop.Text <- "Welcome to Super BreakAway!"
                popupTop.HorizontalAlignment <- HorizontalAlignment.Center  // TODO cannot seem to auto-align these; design-time issue?  recompute layout?
                popupMiddle.Text <- "Move the mouse to control the paddle\nPrevent balls from falling off bottom\nBreak bricks on top to release more\nHave fun!"
                popupBottom.Text <- "Press 'p' to start"
            } |> Async.StartImmediate 
            async { 
                do! Async.Sleep(100)
                while remaining > 0 && active > 0 do
                    do! Async.Sleep(20)
                    do  // this 'do' line is important to memory performance - code below is all sync, so need to execute outside 'async' to avoid Async allocating
                        if popup.IsOpen then () else
                        wantPaddleBeep <- false
                        wantBlockBeep <- false
                        curBeep <- (curBeep + 1) % NUMBEEP
                        let leftPad = Canvas.GetLeft(paddle)
                        for y in 0..HEIGHT-1 do
                            for x in 0..WIDTH-1 do
                                let ball = blocks.[y,x]
                                if ball.State = BlockState.Active then
                                    ball.MoveOneStep()
                                    let top = Canvas.GetTop(ball.Shape)
                                    let left = Canvas.GetLeft(ball.Shape)
                                    if top >= TOPPAD && top < TOPPAD+PADHEIGHT && left >= leftPad && left < leftPad+PADWIDTH then
                                        // hit paddle
                                        ball.HitPaddle(dx=(left - leftPad - HALFPADWIDTH)/HALFPADWIDTH)
                                        wantPaddleBeep <- true
                                    elif top < BOTBRICKS then
                                        // see if hit a stationary brick
                                        let brick = blocks.[int(top / SIZE),int(left / SIZE)]
                                        if brick.State = BlockState.InitialPosition  then
                                            let t = Canvas.GetTop(brick.Shape)
                                            let l = Canvas.GetLeft(brick.Shape)
                                            let intersect = left >= l && left < l+SIZE && top >= t && top < t+SIZE
                                            if intersect then
                                                remaining <- remaining - 1
                                                active <- active + 1
                                                tb.Text <- sprintf "%d bricks remain, %d balls active" remaining active
                                                ball.ReboundOffBrick(dLeft=l-left, dTop=t-top)
                                                brick.BreakAway()
                                                wantBlockBeep <- true
                                    elif top > CANHEIGHT then
                                        // fell off bottom
                                        if CHEAT then
                                            ball.Reflect()
                                        else
                                            ball.Remove()
                                            active <- active - 1
                                            tb.Text <- sprintf "%d bricks remain, %d balls active" remaining active
                        if wantPaddleBeep then
                            playOnce(beeps.[curBeep])
                        if wantBlockBeep then
                            playOnce(blockBeeps.[curBeep])
                if remaining > 0 then
                    tb.Text <- sprintf "left %d bricks" remaining
                    playOnce(loseSound)
                else
                    tb.Text  <- "You Win!!!"
                    playOnce(winSound)
            } |> Async.StartImmediate 
        )

        // to be able to get focus
        cc.IsTabStop <- true
        cc.IsEnabled <- true
        cc.KeyDown.Add(fun keyEA ->
            if keyEA.Key = Input.Key.P then
                popupCanvas.Height <- mainPanel.ActualHeight 
                popupCanvas.Width <- mainPanel.ActualWidth 
                popup.IsOpen <- not popup.IsOpen
                popupTop.Text <- "PAUSE"
                popupMiddle.Text <- "F# - 'fun' is our keyword!"
                popupBottom.Text <- "Press 'p' to unpause and continue"
        )
#if SILVERLIGHT            
        System.Windows.Browser.HtmlPage.Plugin.Focus()
#else
        cc.Focus() |> ignore
#endif
        mainPanel.MouseMove 
        |> Observable.add (fun ea -> 
            let x = ea.GetPosition(canvas).X
            if x < HALFPADWIDTH then
                Canvas.SetLeft(paddle, 0.0)
            elif x <= CANWIDTH - HALFPADWIDTH then
                Canvas.SetLeft(paddle, x - HALFPADWIDTH)
            else 
                Canvas.SetLeft(paddle, CANWIDTH - PADWIDTH)
        )
        cc.Content <- mainPanel
#if SILVERLIGHT
        this.RootVisual <- cc
#else
        this.Content <- cc
        this.SizeToContent <- SizeToContent.WidthAndHeight 
#endif

#if SILVERLIGHT
#else
module Main =
    [<System.STAThread()>] 
    do  
        let app =  new Application() 
        app.Run(new MyApp()) |> ignore 
#endif

Enjoy!  It’s yours to go run with.  I’d be interested to see people do fun enhancements to this code.  I’d also be interested to see people author a similar app on other platforms (iPhone, Android, HTML5 Canvas/Javascript, …) – I think perhaps the .NET platform is terrific in terms of languages and tooling to write this kind of stuff, but I have no comparative experience of my own, and I am curious to know how easy or hard it would be to mimic this app on another platform.

Advertisements

4 Responses to “Game programming in F# (with Silverlight and WPF)”

  1. OLALEKAN said

    This is what Alinko has been looking for, just got the secret of game programming, I think F# really simplify game functional code kudos to Microsoft on Fsharp.–Ali

  2. tmg_tt said

    interesting challenge to flash, to beat over 400 LOC in such game

  3. Mauro said

    Pretty cool. When the world starts falling apart you think you\’re done, but you\’re up for a surprise :-)BTW, can I have your job?

  4. Shawn said

    Holy cow! I won on my 3rd try.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

 
%d bloggers like this: