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:
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.