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
« Previous 1 2 3 Next »
Buy this article as PDF
(incl. VAT)
Buy Linux Magazine
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.
News
-
New Slimbook EVO with Raw AMD Ryzen Power
If you're looking for serious power in a 14" ultrabook that is powered by Linux, Slimbook has just the thing for you.
-
The Gnome Foundation Struggling to Stay Afloat
The foundation behind the Gnome desktop environment is having to go through some serious belt-tightening due to continued financial problems.
-
Thousands of Linux Servers Infected with Stealth Malware Since 2021
Perfctl is capable of remaining undetected, which makes it dangerous and hard to mitigate.
-
Halcyon Creates Anti-Ransomware Protection for Linux
As more Linux systems are targeted by ransomware, Halcyon is stepping up its protection.
-
Valve and Arch Linux Announce Collaboration
Valve and Arch have come together for two projects that will have a serious impact on the Linux distribution.
-
Hacker Successfully Runs Linux on a CPU from the Early ‘70s
From the office of "Look what I can do," Dmitry Grinberg was able to get Linux running on a processor that was created in 1971.
-
OSI and LPI Form Strategic Alliance
With a goal of strengthening Linux and open source communities, this new alliance aims to nurture the growth of more highly skilled professionals.
-
Fedora 41 Beta Available with Some Interesting Additions
If you're a Fedora fan, you'll be excited to hear the beta version of the latest release is now available for testing and includes plenty of updates.
-
AlmaLinux Unveils New Hardware Certification Process
The AlmaLinux Hardware Certification Program run by the Certification Special Interest Group (SIG) aims to ensure seamless compatibility between AlmaLinux and a wide range of hardware configurations.
-
Wind River Introduces eLxr Pro Linux Solution
eLxr Pro offers an end-to-end Linux solution backed by expert commercial support.