DEV Community

Cover image for Build a Water Simulation in Go with Raylib-go
Tim Little
Tim Little

Posted on • Originally published at Medium

Build a Water Simulation in Go with Raylib-go

In this blog post, we will use raylib-go to create a lightweight water simulation for 2D games.

Water simulation
Water simulation

This post aims to create a simulation of water which flows naturally and presents the illusion of flow and volume. Fluid Simulation is a huge topic. To keep things simple, we will use cellular automation to update each cell.

Each cell will follow a set of rules:

  • Gravity — droplets fall straight down if there's space.
  • Side Flow — when blocked, water spreads left and right.
  • Pressure — when blocked on both sides, it flows diagonally.

Game setup

First, we set up the Go module and install the raylib-go package.

mkdir go-watersim go mod init watersim go get -v -u github.com/gen2brain/raylib-go/raylib 
Enter fullscreen mode Exit fullscreen mode

We create a Game struct to hold our game state and to manage the game loop. We create a Draw() method to draw its state to the screen by using the Draw() function on our Droplet, which represents the water.

type Game struct { Width int Height int State [][]Droplet // 2D grid of water droplets [y][x] tileSize int } func (g *Game) Draw() { // Loop through each cell and call the cells Draw() method for y := range g.State { for x := 0; x < len(g.State[y]); x++ { g.State[y][x].Draw(x, y, g.tileSize) } } } 
Enter fullscreen mode Exit fullscreen mode

Next, we create the Droplet entity and create a Draw() method to draw a single water droplet to the screen, represented by a blue square. To work with the cellular simulation, we convert the coordinates to cells.

type Droplet struct { volume float64 // How much water this cell contains (0.0 to 1.0) size int } func (d *Droplet) Draw(x, y, tileSize int) { // Convert grid coordinates to pixel coordinates pixelX := x * tileSize pixelY := y * tileSize if d.volume > 0 { // Draw the blue water rectangle rl.DrawRectangle(int32(pixelX), int32(pixelY), int32(tileSize), int32(tileSize), rl.Blue) } } 
Enter fullscreen mode Exit fullscreen mode

Next, we need to create the Game and its initial cellular state. To assist, we create helper functions to set up the Game and to create the initial state.

func NewGame(width, height, tileSize int) *Game { // Create a new game g := &Game{Width: width, Height: height, tileSize: tileSize} // Create the new game state // divide pixel dimensions by tile size to get grid size g.State = CreateGameState(g.Width/g.tileSize, g.Height/g.tileSize, tileSize) return g } func CreateGameState(newWidth, newHeight, tileSize int) [][]Droplet { // Create a new game state newState := make([][]Droplet, newHeight) // Loop through each row for y := range newHeight { // Create the columns newState[y] = make([]Droplet, newWidth) // Loop through each cell and create a Droplet for x := range newState[y] { newState[y][x] = Droplet{ size: tileSize, } } } return newState } 
Enter fullscreen mode Exit fullscreen mode

Finally, we update our main() function to initialise the raylib window and uses the new helper functions to create the game state and draw it to the screen.

 // Setup the new game var game = NewGame(800, 400, 10) // Initialize Raylib graphics window rl.InitWindow(int32(game.Width), int32(game.Height), "Water simulation") defer rl.CloseWindow() // Create a single water droplet and add it to the screen droplet := Droplet{size: game.tileSize, volume: 1.0} game.State[100/game.tileSize][400/game.tileSize] = droplet // Setup the frame per second rate rl.SetTargetFPS(20) // Main loop for !rl.WindowShouldClose() { // Begin to draw and set the background to black rl.BeginDrawing() rl.ClearBackground(rl.Black) // Draw the game state game.Draw() rl.EndDrawing() } 
Enter fullscreen mode Exit fullscreen mode

This provides us with a cellular grid, with a single water droplet drawn to the screen. Let's confirm this by running the program.

go run main.go 
Enter fullscreen mode Exit fullscreen mode

Water droplet on a black background
Water droplet on a black background

We see our Raylib window with a single blue square drawn on it, representing our water droplet. Not very impressive at the moment!

The code for this section of the guide is available on GitHub.

Logic Rules

We begin to bring life to the game by following logical rules to move the water in a fluid-like manner.

Flow and pressure rules for simulation
Flow and pressure rules for simulation

We start with the impact of gravity, which flows downwards and fills the cell below it. If there is no more water, the interaction is complete. If there is water remaining, the droplet will flow to the sides and diagonally.

Gravity

Gravity pulls the water droplet down at a constant rate. For our simulation, we need to check that the cell directly below the droplet has space, then transfer part of our volume to it.

To ensure there is space, we loop through the state from the bottom upwards. This ensures lower cells are processed first, to prevent water from "teleporting" through other cells.

We create an Update() method on the Game struct to process each frame. We apply the rules to the cell and then draw the new state to the screen.

func (g *Game) Update() { // Create a new state to avoid modifying the current state while reading it newState := CreateGameState(len(g.State[0]), len(g.State), g.tileSize) // Copy current state to new state for y := range g.State { copy(newState[y], g.State[y]) } // Process the simulation from the bottom upwards for y := len(g.State) - 1; y >= 0; y-- { for x := range g.State[y] { // Only process cells that contain water if g.State[y][x].volume > 0 { // Check if we are at the bottom boundary if y+1 < len(g.State) { processWaterCell(x, y, &newState) } } } } // Replace old state with new calculated state g.State = newState } 
Enter fullscreen mode Exit fullscreen mode

We create a new frame and copy the current state into it. We loop from the bottom to ensure we are not overfilling.

For each cell, we run the processWaterCell() method on it, which will apply our logical rules to the Droplet.

func processWaterCell(x, y int, newState *[][]Droplet) { // Try to flow downwards, as if by gravity fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5) } 
Enter fullscreen mode Exit fullscreen mode

This function is simple for now, it transfers the volume from the current cell to the one below it.

We use a fill() function, which transfers volume from one cell to another, and we use the remainder() function to calculate how much space is left in the target cell before transferring the volume to the new one. We rate limit the transfer with the flowRate to help the simulation feel smooth.

// Calculate how much more water a droplet can hold func remainder(droplet Droplet, maxVolume float64) float64 { return maxVolume - droplet.volume } // Fill transfers water between two droplets at a controlled rate func fill(current, target *Droplet, maxVolume, flowRate float64) { // Calculate how much water can be transferred transfer := remainder(*target, maxVolume) // Limit transfer to the flow rate (prevents instant teleportation) if transfer > flowRate { transfer = flowRate } // Move water from source to target current.volume -= transfer target.volume += transfer } 
Enter fullscreen mode Exit fullscreen mode

Finally, we include our Update() method in our game loop to update the droplets:

 // Draw the game state game.Draw()  + // Update the game state based on the rules + game.Update() +  rl.EndDrawing() 
Enter fullscreen mode Exit fullscreen mode

Now we can run the simulation and confirm we have a water droplet affected by gravity.

go run . 
Enter fullscreen mode Exit fullscreen mode

Water droplet affected by gravity
Water droplet affected by gravity

It works!

The water droplet is falling to the ground and stopping when it reaches the bottom of the screen.

However, it is getting duplicated as it falls, so two cells look like they are full.

We can resolve this by changing the height based on its volume and checking if the cell above has volume. If it does, we can fill from the top, if not, we fill from the bottom.

func (g *Game) Draw() { // Loop through each cell and call the cells Draw() method for y := range g.State { for x := 0; x < len(g.State[y]); x++ { // Check if there is water above this cell hasWaterAbove := y > 0 && g.State[y-1][x].volume > 0 g.State[y][x].Draw(x, y, g.tileSize, hasWaterAbove) } } } 
Enter fullscreen mode Exit fullscreen mode

We update our Game.Draw() method to check if there is water in the cell above. We pass this to the Droplet.Draw() method to decide on where to start filling from.

func (d *Droplet) Draw(x, y, tileSize int, hasWaterAbove bool) { // Convert grid coordinates to pixel coordinates pixelX := x * tileSize pixelY := y * tileSize if d.volume > 0 { // Calculate visual height based on water volume // Full volume (1.0) = full tile height, half volume (0.5) = half height height := int(float64(tileSize) * d.volume) // Fill up from the bottom offsetY := tileSize - height // If water above, fill from the top instead if hasWaterAbove { offsetY = 0 } // Draw the blue water rectangle rl.DrawRectangle(int32(pixelX), int32(pixelY+offsetY), int32(tileSize), int32(height), rl.Blue) } 
Enter fullscreen mode Exit fullscreen mode

The Droplet.Draw() method calculates how high each cell should be based on volume and offsets the drawing by the volume amount to fill from the bottom. If there is water above it, we fill from the top.

Let's run our game now and see what the result is:

go run . 
Enter fullscreen mode Exit fullscreen mode

Smooth water fall
Smooth water fall

It looks much smoother now. The droplet is falling in a continuous motion to the bottom.

The code for this section can be found in the GitHub repository

Start a flow of water

Currently, we are generating a single droplet. However, we want a flow of water to see how the droplets interact with each other.

We do this by creating a helper function to generate Droplet objects.

func CreateWaterGenerator(x, y, tileSize int, state *[][]Droplet) { droplet := Droplet{size: tileSize, volume: 1.0} (*state)[y][x] = droplet } 
Enter fullscreen mode Exit fullscreen mode

We replace the manual creation of the Droplet in the main game loop and count the number of frames to add water regularly.

- // Create a single water droplet and add it to the screen - droplet := Droplet{size: game.tileSize, volume: 1.0} - game.State[100/game.tileSize][400/game.tileSize] = droplet + // Set up a counter, so we can spawn new water at a rate + frameCount := 0 + flowStartX := 100 / game.tileSize + flowStartY := 100 / game.tileSize + CreateWaterGenerator(flowStartX, flowStartY, game.tileSize, &game.State)   // Setup the frame per second rate rl.SetTargetFPS(20)   // Main loop for !rl.WindowShouldClose() { + frameCount++   // Begin to draw and set the background to black rl.BeginDrawing() rl.ClearBackground(rl.Black)  + // Add new water every 5 frames (creates continuous water stream) + if frameCount%5 == 0 { + CreateWaterGenerator(flowStartX, flowStartY, game.tileSize, &game.State) + CreateWaterGenerator(flowStartX+1, flowStartY, game.tileSize, &game.State) + CreateWaterGenerator(flowStartX-1, flowStartY, game.tileSize, &game.State) + } + 
Enter fullscreen mode Exit fullscreen mode

Now, we run the program, we expect there to be water dripping from a point at the top of the screen.

go run . 
Enter fullscreen mode Exit fullscreen mode

Water flowing but stacking

Ah, that does not look right!

On the plus side, we have our Droplet objects continually following. On the downside, they are stacking on top of each other. We address that by adding sideways flow in the next section.

The code for this section can be found in the GitHub repository

Flow (Sideways)

The stacking is happening as droplets are incompressible and cannot flow downwards anymore. To fix this, we update our code to flow sideways.

We start by checking that there is water still in the cell. If there is, we start to flow the water out to the side cells.

 func processWaterCell(x, y int, newState *[][]Droplet) { // Try to flow downwards, as if by gravity fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5) + + // If all water flowed down, no need to continue + if (*newState)[y][x].volume == 0 { + return + } + + // If water can still flow down, don't try other directions yet + if canFlowDown(x, y, newState) { + return + } + + // Water spreads sideways when blocked below + tryHorizontalFlow(x, y, newState) +  } 
Enter fullscreen mode Exit fullscreen mode

The canFlowDown() checks if there is still space to flow downwards, if there is, we exit and process the remainder in the next frame.

The tryHorizontalFlow() method cascades to the left and the right. It checks the next 3 cells and adds a fraction of the remaining volume to the cells. This allows the water to "settle" when it cannot flow downwards.

func canFlowDown(x, y int, state *[][]Droplet) bool { return y+1 < len(*state) && (*state)[y+1][x].volume < 1.0 } func tryHorizontalFlow(x, y int, state *[][]Droplet) { current := &(*state)[y][x] // Only cascade if there's water below hasWaterBelow := y+1 < len(*state) && (*state)[y+1][x].volume > 0.5 if !hasWaterBelow { return } // Cascade right - distribute to multiple cells for offset := 1; offset <= 3 && x+offset < len((*state)[y]); offset++ { target := &(*state)[y][x+offset] if target.volume < current.volume { flowRate := (current.volume - target.volume) * 0.1 / float64(offset) fill(current, target, 1.0, flowRate) } } // Cascade left - distribute to multiple cells for offset := 1; offset <= 3 && x-offset >= 0; offset++ { target := &(*state)[y][x-offset] if target.volume < current.volume { flowRate := (current.volume - target.volume) * 0.1 / float64(offset) fill(current, target, 1.0, flowRate) } } } 
Enter fullscreen mode Exit fullscreen mode

Now, when we run, we expect to see the water flow to the sides, with the surrounding cells taking a percentage of the water.

go run . 
Enter fullscreen mode Exit fullscreen mode

Sideways Flow
Sideways Flow

Excellent, the water is flowing to the side, spreading out and flowing along the bottom of the screen.

There is still stacking, and the effect is rather blocky at the edges. This is a result of volume still being in the current cell after the downward and sideways movement. Let's fix that in the next section.

The code for this section can be found in the GitHub repository

Pressure (diagonal)

To present a smoother flow of water, we can introduce more pressure dynamics. As water is incompressible, we would need the water to flow in all downward trajectories. Let's update the processWaterCell() function to try diagonal transfer when there is still volume.

 func processWaterCell(x, y int, newState *[][]Droplet) { // Try to flow downwards, as if by gravity fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5)   // If all water flowed down, no need to continue if (*newState)[y][x].volume == 0 { return }   // If water can still flow down, don't try other directions yet if canFlowDown(x, y, newState) { return }   // Water spreads sideways when blocked below tryHorizontalFlow(x, y, newState)  + if (*newState)[y][x].volume > 0 { + tryDiagonalFlow(x, y, newState) + } +  } 
Enter fullscreen mode Exit fullscreen mode

We add a tryDiagonalFlow() function to flow diagonally. It checks that the target cell is within bounds and then transfers volume if there is space.

func tryDiagonalFlow(x, y int, state *[][]Droplet) { current := &(*state)[y][x] // Flow diagonally down-right if space is available if x+1 < len((*state)[y]) && y+1 < len(*state) && (*state)[y+1][x+1].volume < 1.0 { fill(current, &(*state)[y+1][x+1], 1.0, 0.25) } // Flow diagonally down-left if space is available if x > 0 && y+1 < len(*state) && (*state)[y+1][x-1].volume < 1.0 { fill(current, &(*state)[y+1][x-1], 1.0, 0.25) } } 
Enter fullscreen mode Exit fullscreen mode

Let's run the program. We expect the water flow to be smoother and less blocky.

go run . 
Enter fullscreen mode Exit fullscreen mode

Smooth flow
Smooth flow

Great! The water is smoother and is slowly filling the screen in tiny increments.

The code for this section can be found in the GitHub repository

Obstacles

At the moment, the water is falling to the bottom and flowing outwards. It is not very impressive.

Let's update the scene to include obstacles that the water interacts with. The water should be blocked by the obstacles and to flow over it like natural water.

We start by adding a flag to our cell to indicate if it is an obstacle.

 type Droplet struct { - volume float64 // How much water this cell contains (0.0 to 1.0) - size int + volume float64 // How much water this cell contains (0.0 to 1.0) + size int + isObstacle bool // Is this cell an obstacle?  } 
Enter fullscreen mode Exit fullscreen mode

We updated the Draw() method to draw an obstacle in brown rather than blue.

 func (d *Droplet) Draw(x, y, tileSize int, hasWaterAbove bool) { // Convert grid coordinates to pixel coordinates pixelX := x * tileSize pixelY := y * tileSize  + if d.isObstacle { + // Draw obstacle as brown rectangle + rl.DrawRectangle(int32(pixelX), int32(pixelY), int32(tileSize), int32(tileSize), rl.Brown) + } +  if d.volume > 0 { // Calculate visual height based on water volume // Full volume (1.0) = full tile height, half volume (0.5) = half height height := int(float64(tileSize) * d.volume) 
Enter fullscreen mode Exit fullscreen mode

Next, we update our collision logic in our rules to ensure we are only flowing to cells which are not obstacles.

We start with the canFlowDown function:

 func canFlowDown(x, y int, state *[][]Droplet) bool { - return y+1 < len(*state) && (*state)[y+1][x].volume < 1.0 + return y+1 < len(*state) && (*state)[y+1][x].volume < 1.0 && !(*state)[y+1][x].isObstacle  } 
Enter fullscreen mode Exit fullscreen mode

Then the tryHorizontalFlow() function:

 func tryHorizontalFlow(x, y int, state *[][]Droplet) { current := &(*state)[y][x]   // Only cascade if there's water below hasWaterBelow := y+1 < len(*state) && (*state)[y+1][x].volume > 0.5 if !hasWaterBelow { return }   // Cascade right - distribute to multiple cells for offset := 1; offset <= 3 && x+offset < len((*state)[y]); offset++ { target := &(*state)[y][x+offset] - if target.volume < current.volume { + if target.volume < current.volume && !target.isObstacle {  flowRate := (current.volume - target.volume) * 0.1 / float64(offset) fill(current, target, 1.0, flowRate) } }   // Cascade left - distribute to multiple cells for offset := 1; offset <= 3 && x-offset >= 0; offset++ { target := &(*state)[y][x-offset] - if target.volume < current.volume { + if target.volume < current.volume && !target.isObstacle {  flowRate := (current.volume - target.volume) * 0.1 / float64(offset) fill(current, target, 1.0, flowRate) } } } 
Enter fullscreen mode Exit fullscreen mode

And finally the tryDiagonalFlow() function:

 func tryDiagonalFlow(x, y int, state *[][]Droplet) { current := &(*state)[y][x]   // Flow diagonally down-right if space is available - if x+1 < len((*state)[y]) && y+1 < len(*state) && (*state)[y+1][x+1].volume < 1.0 { + if x+1 < len((*state)[y]) && y+1 < len(*state) && (*state)[y+1][x+1].volume < 1.0 && !(*state)[y+1][x+1].isObstacle {  fill(current, &(*state)[y+1][x+1], 1.0, 0.25) }   // Flow diagonally down-left if space is available - if x > 0 && y+1 < len(*state) && (*state)[y+1][x-1].volume < 1.0 { + if x > 0 && y+1 < len(*state) && (*state)[y+1][x-1].volume < 1.0 && !(*state)[y+1][x-1].isObstacle {  fill(current, &(*state)[y+1][x-1], 1.0, 0.25) } } 
Enter fullscreen mode Exit fullscreen mode

We will also need to update the main gravity logic in the processWaterCell() function:

 func processWaterCell(x, y int, newState *[][]Droplet) { - // Try to flow downwards, as if by gravity - fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5) + // Try to flow downwards, as if by gravity (but not into obstacles) + if y+1 < len(*newState) && !(*newState)[y+1][x].isObstacle { + fill(&(*newState)[y][x], &(*newState)[y+1][x], 1.0, 0.5) + } 
Enter fullscreen mode Exit fullscreen mode

Great!

Now we create some helper functions to create the obstacles on the screen.

func CreateHorizontalObstacle(x, y, size int, state *[][]Droplet) { for offset := range size { (*state)[y][x+offset].isObstacle = true (*state)[y+1][x+offset].isObstacle = true (*state)[y+2][x+offset].isObstacle = true } } func CreateVerticleObstacle(x, y, size int, state *[][]Droplet) { for offset := range size { (*state)[y+offset][x].isObstacle = true (*state)[y+offset][x+1].isObstacle = true (*state)[y+offset][x+2].isObstacle = true } } 
Enter fullscreen mode Exit fullscreen mode

While we are looking at object creation, we can simplify the water generation code by using the same looping method.

func CreateWaterGenerator(x, y, tileSize int, state *[][]Droplet) { - droplet := Droplet{size: tileSize, volume: 1.0} - (*state)[y][x] = droplet + for xOffset := 0; xOffset <= 4; xOffset++ { + droplet := Droplet{size: tileSize, volume: 1.0} + (*state)[y][x+xOffset] = droplet + + }  } 
Enter fullscreen mode Exit fullscreen mode

Finally, we can update our main() function to add obstacles to block the path of the water and to DRY up the water generation code:

 func main() { // Setup the new game var game = NewGame(800, 400, 10)   // Initialize Raylib graphics window rl.InitWindow(int32(game.Width), int32(game.Height), "Water simulation") defer rl.CloseWindow()   // Set up a counter, so we can spawn new water at a rate frameCount := 0 - flowStartX := 100 / game.tileSize + flowStartX := 400 / game.tileSize  flowStartY := 100 / game.tileSize CreateWaterGenerator(flowStartX, flowStartY, game.tileSize, &game.State)  + CreateVerticleObstacle(30, 20, 10, &game.State) + + CreateHorizontalObstacle(0, 30, 50, &game.State) + CreateHorizontalObstacle(40, 20, 40, &game.State) +   // Setup the frame per second rate rl.SetTargetFPS(20)   // Main loop for !rl.WindowShouldClose() { frameCount++   // Begin to draw and set the background to black rl.BeginDrawing() rl.ClearBackground(rl.Black)   // Add new water every 5 frames (creates continuous water stream) - if frameCount%5 == 0 { + if frameCount%3 == 0 {  CreateWaterGenerator(flowStartX, flowStartY, game.tileSize, &game.State) - CreateWaterGenerator(flowStartX+1, flowStartY, game.tileSize, &game.State) - CreateWaterGenerator(flowStartX-1, flowStartY, game.tileSize, &game.State)  } 
Enter fullscreen mode Exit fullscreen mode

Moment of truth…

Let's run the code and see if it is flowing over the obstacles as we expect:

go run . 
Enter fullscreen mode Exit fullscreen mode

Water flowing down obstacles
Water flowing down obstacles

Amazing, the water is flowing downwards until it reaches the obstacles, then it flows across the top of the obstacle until it reaches the side of the screen or another obstacle.

There is still stacking when water is falling on top of other water.

Insight: This doesn't really behave like water.
What we've built behaves more like sand than water. This approach is lightweight and fun for games that need falling particles like sand traps, dust, lava.

It might have been better to call this Sand Simulation!

Sand Simulation
Sand Simulation

The code for this section can be found in the GitHub repository

Closing the gap

This method works for a simple game that requires sand physics. Think of a sand avalanche in a side scroller that traps the player.

However, the water implementation would need some modification. It needs to calculate pressure, velocity and momentum to get the natural fluid motion.

We could go deeper, we would need to add water tension, viscosity and turbulence. The simulation would need to solve the Navier–Stokes equations for flow behaviours. An excellent example of this is Sebastian Lague's Simulating Fluids.

Conclusion

In this post, we:

  • Created a simple water/sand simulator using raylib-go
  • Use a simple set of rules to simulate complex behaviours
  • Added obstacles that the water would flow across

If you found this useful, consider giving it a clap or sharing! You can also check out the GitHub repo and try modifying the rules to create lava, smoke, or sand effects.

Next up, I am going to look into building a simple game with raylib-go, to explore more of the raylib library and to have a bit of fun with the output.

Also published to:

Top comments (0)