Game development with Go and the Fyne framework

Great Tennis

The Chipshot video game presented below consists of plain-vanilla Go code; the graphics library is the platform-independent Fyne [6], which I covered with a photo sorter in a recent column [7]. Figures 4 to 6 show the game in action. The user pushes the two white sliders top left to set the launch speed of the ball between 0 and 30 and sets the angle of attack to a value between 0 and 90 degrees. If you then click on the Shoot button in the top left corner, the ball starts flying on its parabolic path.

As soon as the ball hits the ground again, it rolls for a little while longer – into the goal with any luck, incrementing the Goals counter at the top by one. However, if the ball comes down in front of goalkeeper, it gets caught, and the keeper will potentially laugh gleefully at foiling the attempt. The same is true if the attacker shoots too hard or doesn't stick the boot in hard enough, which causes the ball to fly over the goal or die on its way rolling towards the goal.

If the player scores, the score is notched up and the game creates a new situation by rearranging goalkeeper and goal. If the attacker fails and the ball does not go into the goal, the player can try the same situation again with different controller settings, but the game resets the goal counter to zero as a penalty.

Programmed Randomness

Listing 3 sets up the game. To prevent the same initial position always coming up after a program restart, line 34 uses rand.Seed() to set Go's internal random number generator to a value that, when seeded with the nanoseconds of the current time, yields fairly widely scattered initial data.

Listing 3

chipshot.go

001 package main
002 import (
003   "fmt"
004   col "golang.org/x/image/colornames"
005   "image/color"
006   "math/rand"
007   "os"
008   "time"
009   "fyne.io/fyne/v2"
010   "fyne.io/fyne/v2/app"
011   "fyne.io/fyne/v2/canvas"
012   "fyne.io/fyne/v2/container"
013   "fyne.io/fyne/v2/data/binding"
014   "fyne.io/fyne/v2/widget"
015 )
016
017 type UI struct {
018   ball, goal, goalText, goalie,
019   goalieText fyne.CanvasObject
020 }
021
022 var (
023   gameWidth  = float32(1200)
024   gameHeight = float32(800)
025   goalWidth  = float32(30)
026   goalHeight = float32(60)
027   minDist    = 30
028   textHover  = float32(50)
029 )
030
031 func main() {
032   a := app.New()
033   ui := UI{}
034   rand.Seed(time.Now().UnixNano())
035   w := a.NewWindow("Chipshot")
036   w.Resize(fyne.NewSize(gameWidth, gameHeight))
037   w.SetFixedSize(true)
038   goalieDist, goalDist := itemsXPos()
039   ui.goalie = canvas.NewRectangle(col.Lightsalmon)
040   ui.goalie.Move(fyne.NewPos(goalieDist, gameHeight-goalHeight))
041   ui.goalie.Resize(fyne.NewSize(goalWidth, goalHeight))
042   ui.goal = canvas.NewRectangle(col.Lightgreen)
043   ui.goal.Move(fyne.NewPos(goalDist, gameHeight-goalHeight))
044   ui.goal.Resize(fyne.NewSize(goalWidth, goalHeight))
045   ui.ball = canvas.NewCircle(col.Red)
046   ui.ball.Move(fyne.NewPos(0, gameHeight-30))
047   ui.ball.Resize(fyne.NewSize(15, 30))
048   ui.goalText = canvas.NewText("Goal!!!", col.Red)
049   placeTextHover(ui.goalText, ui.goal)
050   ui.goalieText = canvas.NewText("Caught it!!!", col.Red)
051   placeTextHover(ui.goalieText, ui.goalie)
052   play := container.NewWithoutLayout(ui.goal, ui.goalie, ui.ball, ui.goalText, ui.goalieText)
053   velo := binding.NewFloat()
054   veloSlide := widget.NewSliderWithData(0, 30, velo)
055   formVelo := binding.FloatToStringWithFormat(velo, "Velocity: %0.2f")
056   veloLabel := widget.NewLabelWithData(formVelo)
057   veloSlide.SetValue(15)
058   angle := binding.NewFloat()
059   angleSlide := widget.NewSliderWithData(0, 90, angle)
060   formAngle := binding.FloatToStringWithFormat(angle, "Angle: %0.2f")
061   angleLabel := widget.NewLabelWithData(formAngle)
062   angleSlide.SetValue(45)
063   countText := canvas.NewText("Goals: 0", &color.Black)
064   count := 0
065   shoot := widget.NewButton("Shoot", func() {
066     v, _ := velo.Get()
067     a, _ := angle.Get()
068     success := animate(v, a, ui)
069     if success {
070       count++
071       goalieDist, goalDist := itemsXPos()
072       ui.goalie.Move(fyne.NewPos(goalieDist, gameHeight-goalHeight))
073       ui.goal.Move(fyne.NewPos(goalDist, gameHeight-goalHeight))
074       placeTextHover(ui.goalieText, ui.goalie)
075       placeTextHover(ui.goalText, ui.goal)
076     } else {
077       count = 0
078     }
079     countText.Text = fmt.Sprintf("Goals: %d", count)
080     countText.Refresh()
081     // return ball to origin
082     ui.ball.Move(fyne.NewPos(0, gameHeight-30))
083   })
084   quit := widget.NewButton("Quit",
085     func() { os.Exit(0) })
086   buttons := container.NewHBox(shoot, quit, countText)
087   con := container.NewVBox(play, buttons, veloSlide,
088     veloLabel, angleSlide, angleLabel)
089   w.SetContent(con)
090   w.ShowAndRun()
091 }
092
093 func randRange(from, to int) float32 {
094   return float32(rand.Intn(to-from+1) + from)
095 }
096
097 func itemsXPos() (float32, float32) {
098   d1 := randRange(minDist, 2*int(gameWidth)/3)
099   d2 := randRange(int(d1)+minDist, int(gameWidth-goalWidth))
100   return d1, d2
101 }

The user interface (UI) elements used are defined by the UI type structure in line 17, which contains the soccer ball, the goal, the goalkeeper, and the text displayed above the goal or goalkeeper. The global variables in the block starting in line 22 define the dimensions of the playing field and the players. The main() function starting in line 31 first creates a new Fyne application. It then defines a window and sets it to a fixed size. It represents the goal and goalkeeper as Fyne rectangles in light green and salmon pink colors.

The itemsXPos() function starting in line 97 returns the positions for the goal and goalkeeper both at program startup time and after mastering a standard situation for a new round with different parameters. It also ensures that no nonsensical constellations occur, such as the goalkeeper standing behind the goal.

When the goalkeeper intercepts a ball or it hits the goal, text appears above these game figures, flashing three times to report the event. The associated graphical widgets are defined in line 48 and line 50, but the placeTextHover() function (defined later on in Listing 4) uses Hide() to ensure that they are initially hidden. It is only later that the blink() function causes them to flash their text several times as needed.

Listing 4

animate.go

01 package main
02 import (
03   "math"
04   "time"
05   "fyne.io/fyne/v2"
06   "fyne.io/fyne/v2/canvas"
07 )
08
09 func animate(velo float64, angle float64, ui UI) bool {
10   nap := 10 // ms
11   now := 0
12   angle = math.Pi * angle / 180 // radient
13   rollout := 20
14   for {
15     pos := ui.ball.Position()
16     x, y := chipShot(velo, angle, float64(now)/100)
17     if y == 0 {
18       rollout--
19       if rollout < 0 {
20         break
21       }
22     }
23     goalYOff := float32(30)
24     pos.X = float32(x) * 20
25     pos.Y = gameHeight - goalYOff - float32(y)*100
26     ui.ball.Move(pos)
27     canvas.Refresh(ui.ball)
28     // goal?
29     if pos.X >= ui.goal.Position().X &&
30       pos.X <= ui.goal.Position().X+ui.goal.Size().Width &&
31       pos.Y > gameHeight-goalYOff-ui.goal.Size().Height {
32       go blink(ui.goalText)
33       return true
34     }
35     // goalie?
36     if pos.X >= ui.goalie.Position().X &&
37       pos.X <= ui.goalie.Position().X+ui.goalie.Size().Width &&
38       pos.Y > gameHeight-goalYOff-ui.goalie.Size().Height {
39       go blink(ui.goalieText)
40       break
41     }
42     time.Sleep(time.Duration(nap) * time.Millisecond * time.Duration(nap))
43     now += nap
44   }
45   return false
46 }
47
48 func blink(tw fyne.CanvasObject) {
49   for i := 0; i < 3; i++ {
50     tw.Show()
51     canvas.Refresh(tw)
52     time.Sleep(250 * time.Millisecond)
53     tw.Hide()
54     canvas.Refresh(tw)
55     time.Sleep(250 * time.Millisecond)
56   }
57 }
58
59 func placeTextHover(tw, w fyne.CanvasObject) {
60   textPos := w.Position()
61   textPos.Y = textPos.Y - textHover
62   tw.Move(textPos)
63   tw.Hide()
64 }

The ball is shown as a filled circle; it is created and filled with red color in line 45. The virtual ball starts at X position  , which is the left edge of the field. Line 52 packs all these widgets into the pitch container play, which line 87 later attaches to the buttons and sliders that the user uses to influence what happens in the game. The two slider knobs velo and angle can be moved with the mouse. Thanks to Fyne's binding interface, the slider knobs display the set value without any delay in the associated label in each case – very convenient.

The Shoot button triggers a shot, using the values for initial velocity and the ball launch angle specified in the sliders. The associated callback function starting in line 65 first reads the set controller values and then calls the animate() function from Listing 4. This draws the ball's path onto the playfield and returns a value of true if the ball lands in the goal. If it dies on the way to the goal, or is intercepted by the goalkeeper, a value of false is returned. This allows the main program to keep score.

In the case of success, the goal counter count is incremented by one, and itemsXPos() defines a new game situation. The Fyne Move() function adjusts the widgets for the goal and the goalie to the new positions, and the associated text panels also move with them. As with all changes to UI widgets, a call to Refresh() is then needed to bring the adjusted playing field onto the screen.

As is common in graphical applications, the main program first defines all possible responses to user input and then enters the infinite main event loop in line 90 with ShowAndRun(). If the user at any point in time clicks the Quit button, os.Exit() heralds the end of the program, and the UI folds without a sound.

Action!

Now, the animate() function in Listing 4 defines the action on the playing field when the user clicks Shoot. Using the initial velocity of the ball (velo) and the launch angle in degrees, it draws the trajectory into the game container and evaluates any collisions of the ball with the goalkeeper or the goal based on their current positions.

To do this, line 12 converts the degree value of the launch angle from the controller into radian format. The infinite loop starting in line 14 processes the video game's animation by calculating individual successive frames at intervals of 10ms. While doing so, the call to the chipShot() function (from Listing 1) in line 16 determines the ball's position on the parabolic path associated with the current frame for the now time value as X and Y values. This is done 100 times per second to ensure a smooth animation without hiccups. Lines 26 and 27 refresh the ball position for each frame; nothing else moves on the field during the flight phase.

When the ball ends its trajectory and returns to earth, the physics function chipShot() returns a Y value of zero, and – as a simplified approximation – the variable rollout lets the ball continue rolling along the ground for another 20 frames. In reality, it would bounce back into the air and only roll out after a few hops depending on the substrate friction, but the program ignores this to keep the formula simple.

Any collisions of the ball with the goal or the goalkeeper are calculated by the if constructs in lines 29 and 36; they check whether the current ball position is somewhere within the geometric coordinates of the goal or the goalkeeper. They report a hit with a flashing text if the ball rolls into the goal or enable the goalkeeper message if the keeper has grabbed it.

Before the for loop moves on to the next round, line 42 sleeps for 10ms and adds the nap to the current time in now. Then it moves on to the next frame. The flashing display for a goal or a save is handled by the blink() function starting in line 48. It is called on each event as a concurrently running goroutine using go func from animate() so that it does not hold up the display. Instead, it runs in the background while the main program can continue to handle user input.

The chipshot binary is created using the sequence from Listing 5 in the typical Go style, with the compiler first resolving the packages used in the code and their dependencies from GitHub.

Listing 5

Creating a Binary

$ go mod init chipshot
$ go mod tidy
$ go build chipshot.go animate.go physics.go
$ ./chipshot

Buy this article as PDF

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy Linux Magazine

SINGLE ISSUES
 
SUBSCRIPTIONS
 
TABLET & SMARTPHONE APPS
Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

  • Treasure Hunt

    A geolocation guessing game based on the popular Wordle evaluates a player's guesses based on the distance from and direction to the target location. Mike Schilli turns this concept into a desktop game in Go using the photos from his private collection.

  • Team Spirit

    Instead of the coach determining the team lineup, an algorithm selects the players based on their strengths for Mike Schilli's amateur soccer team.

  • Straight to the Point

    With the Fyne framework, Go offers an easy-to-use graphical interface for all popular platforms. As a sample application, Mike uses an algorithm to draw arrows onto images.

  • Gaming

    Try your luck with Rocket League, Fear Equation, and Master of Orion.

  • GUI Apps with Fyne

    The Fyne toolkit offers a simple way to build native apps that work across multiple platforms. We show you how to build a to-do list app to demonstrate Fyne's power.

comments powered by Disqus
Subscribe to our Linux Newsletters
Find Linux and Open Source Jobs
Subscribe to our ADMIN Newsletters

Support Our Work

Linux Magazine content is made possible with support from readers like you. Please consider contributing when you’ve found an article to be beneficial.

Learn More

News