Contents

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.

/images/water-simulation/watersim.gif 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

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)
  }
 }
}

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)
 }
}

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
}

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()
 }

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

/images/water-simulation/hello.png 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.

/images/water-simulation/rules.png 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

}

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)
}

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
}

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()

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

go run .

/images/water-simulation/gravity.gif 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)
  }
 }
}

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)
 } 

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 .

/images/water-simulation/smooth-fall.gif 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
}

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)
+               }
+

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

go run .

/images/water-simulation/stack.gif

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)
+
 }

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)
  }
 }
}

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 .

/images/water-simulation/sideways-flow.gif 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)
+       }
+
 }

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)
 }
}

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

go run .

/images/water-simulation/smooth-flow.gif 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?
 }

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)

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
 }

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)
                }
        }
 }

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)
        }
 }

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)
+       }

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
 }
}

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
+
+       }
 }

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)
                }

Moment of truth…

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

go run .

/images/water-simulation/watersim.gif 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!

/images/water-simulation/sand.gif 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.