Kotlin Game Development: Game Scene

Gamedev |

Updated on

Now that we have all of the required components we can start working on the actual game mechanics. In this post we’re going to create the GameScene class, the main scene where most of the action happens.

Thumbnail

This is the eighth part of the “Kotlin Game Development” series and it’s better if you read it in chronological order:

  1. Part 1 - Introduction
  2. Part 2 - Creating a Scene
  3. Part 3 - Controller Input
  4. Part 4 - Game Loop
  5. Part 5 - Game Factory
  6. Part 6 - Main Menu
  7. Part 7 - Model
  8. Part 8 - Game Scene (you are here)
  9. Part 9 - Finalizing The Game

Movement

Let’s think about snake movement. When should it move? We need some real numbers. The easiest way to move the snake is to call it’s move method on every scene update. It might seem like a good idea but let’s think about how many times that method is supposed to be called? The answer is: we don’t know and we can’t know, most likely it will be called too often. Not only does it depend from machine to machine, it also can be called hundreds of times per second which will move the snake too fast making our game unplayable.

Our game world is just a grid of squares, so a square seems to be a great distance unit. How can we describe the speed? Distance units per second sounds reasonable so let’s specify our requirements:

The snake needs to move at a fixed pace of 1 square per 300 milliseconds, roughly 3 squares per second. The speed can be increased as the game progresses, feel free to play with this parameter.

How can we achieve such a behavior? I suggest we make an assumption that the scene will be updated faster than 3 times per second so we need to move the snake on some updates but keep it still during other invocations of that same method.

Basically, we need a timer. Let’s add a new class and name it Timer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package com.bubelov.snake.util

class Timer(private val duration: Long) {
    private var remainingTime = duration

    fun update(timePassed: Long) {
        remainingTime -= timePassed
    }

    fun timeIsUp() = remainingTime <= 0

    fun reset() {
        remainingTime = duration
    }
}

This class also has an update method, just like our scene but it only holds the remaining time until it should be fired so we can keep updating it but it will not be fired until the time comes. In case the time is up we should perform our delayed event and reset the timer (if we want it to be repeating).

Game Scene

It looks like now we have everything to implement the game scene, let’s add a new class and call it GameScene:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
package com.bubelov.snake.scene

import com.bubelov.snake.engine.Game
import com.bubelov.snake.engine.Input
import com.bubelov.snake.engine.Scene
import com.bubelov.snake.model.Apple
import com.bubelov.snake.model.Direction
import com.bubelov.snake.model.Snake
import com.bubelov.snake.model.SnakeBodyPart
import com.bubelov.snake.util.Timer
import java.awt.Color
import java.awt.Graphics2D
import java.awt.event.KeyEvent
import java.util.concurrent.TimeUnit

class GameScene(game: Game) : Scene(game) {
    private val snake = Snake(WORLD_WIDTH / 2, WORLD_HEIGHT / 2)

    private lateinit var apple: Apple

    private val snakeMoveTimer = Timer(TimeUnit.MILLISECONDS.toNanos(300))

    init {
        placeApple()
    }

    override fun update(nanosecondsPassed: Long) {
        if (gameIsOver()) {
            // game.scene = GameOverScene(game) TODO Implement later
            return
        }

        processInput()

        snakeMoveTimer.update(nanosecondsPassed)

        if (snakeMoveTimer.timeIsUp()) {
            snake.move()
            val head = snake.head

            if (head.x < 1) {
                head.x = WORLD_WIDTH
            }

            if (head.x > WORLD_WIDTH) {
                head.x = 1
            }

            if (head.y < 1) {
                head.y = WORLD_HEIGHT
            }

            if (head.y > WORLD_HEIGHT) {
                head.y = 1
            }

            if (head.x == apple.x && head.y == apple.y) {
                val body = snake.body
                val lastPart = body[body.size - 1]
                body.add(SnakeBodyPart(lastPart.x, lastPart.y))
                placeApple()
            }

            snakeMoveTimer.reset()
        }
    }

    override fun draw(graphics: Graphics2D) {
        graphics.apply {
            color = Color.black
            fillRect(0, 0, game.width, game.height)
            drawSnake(this)
            drawApple(this)
        }
    }

    private fun processInput() {
        for (event in game.input.consumeEvents()) {
            when (event) {
                is Input.Event.KeyPressed -> {
                    when (event.data.keyCode) {
                        KeyEvent.VK_UP -> snake.direction = Direction.UP
                        KeyEvent.VK_RIGHT -> snake.direction = Direction.RIGHT
                        KeyEvent.VK_DOWN -> snake.direction = Direction.DOWN
                        KeyEvent.VK_LEFT -> snake.direction = Direction.LEFT
                    }
                }
            }
        }
    }

    private fun drawSnake(graphics: Graphics2D) {
        graphics.apply {
            color = Color.green

            snake.body.forEach { part ->
                fillRect(
                    part.x * CELL_SIZE - CELL_SIZE,
                    game.screenSize.height - part.y * CELL_SIZE,
                    CELL_SIZE,
                    CELL_SIZE
                )
            }
        }
    }

    private fun drawApple(graphics: Graphics2D) {
        graphics.apply {
            color = Color.red

            fillRect(
                apple.x * CELL_SIZE - CELL_SIZE,
                game.screenSize.height - apple.y * CELL_SIZE,
                CELL_SIZE,
                CELL_SIZE
            )
        }
    }

    private fun placeApple() {
        var x = (1 + (Math.random() * WORLD_WIDTH)).toInt()
        var y = (1 + (Math.random() * WORLD_HEIGHT)).toInt()

        while (!isCellEmpty(x, y)) {
            if (x < WORLD_WIDTH) {
                x++
            } else {
                if (y < WORLD_HEIGHT) {
                    x = 1
                    y++
                } else {
                    x = 1
                    y = 1
                }
            }
        }

        apple = Apple(x, y)
    }

    private fun isCellEmpty(x: Int, y: Int) = snake.body.none { it.x == x && it.y == y }

    private fun gameIsOver(): Boolean {
        if (snake.body.size == WORLD_WIDTH * WORLD_HEIGHT) {
            return true
        }

        snake.body.forEachIndexed { index, part ->
            if (index > 0 && part.x == snake.head.x && part.y == snake.head.y) {
                return true
            }
        }

        return false
    }

    companion object {
        const val WORLD_WIDTH = 12
        const val WORLD_HEIGHT = 12
        const val CELL_SIZE = 55
    }
}

Let’s go through this code line by line to better understand what’s happening:

1
2
3
4
5
6
7
8
9
private val snake = Snake(WORLD_WIDTH / 2, WORLD_HEIGHT / 2)

private lateinit var apple: Apple

private val snakeMoveTimer = Timer(TimeUnit.MILLISECONDS.toNanos(300))

init {
    placeApple()
}

Here we are just instantiating our snake and apple and setting up snake move timer to make sure it won’t run too fast.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
override fun update(nanosecondsPassed: Long) {
    if (gameIsOver()) {
        // game.scene = GameOverScene(game) TODO Implement later
        return
    }

    processInput()

    snakeMoveTimer.update(nanosecondsPassed)

    if (snakeMoveTimer.timeIsUp()) {
        snake.move()
        val head = snake.head

        if (head.x < 1) {
            head.x = WORLD_WIDTH
        }

        if (head.x > WORLD_WIDTH) {
            head.x = 1
        }

        if (head.y < 1) {
            head.y = WORLD_HEIGHT
        }

        if (head.y > WORLD_HEIGHT) {
            head.y = 1
        }

        if (head.x == apple.x && head.y == apple.y) {
            val body = snake.body
            val lastPart = body[body.size - 1]
            body.add(SnakeBodyPart(lastPart.x, lastPart.y))
            placeApple()
        }

        snakeMoveTimer.reset()
    }
}

This part is more interesting. The first thing we need to do on each update is to make sure the game is still playable. If the game is over (snake ate itself), we need to transition to the GameOver scene which will be implemented in the next post.

The next step is to process the user input. After that, we need to update the snake move timer and check if it’s time to move the snake. In case we need to move the snake, we should also check that it stays inside the screen bounds. That’s why we need those 4 head position checks.

We should also check if the snake has reached an apple. In that case we need to place another apple somewhere on the game screen and we should also elongate the snake length.

Let’s move to the next method:

1
2
3
4
5
6
7
8
override fun draw(graphics: Graphics2D) {
    graphics.apply {
        color = Color.black
        fillRect(0, 0, game.width, game.height)
        drawSnake(this)
        drawApple(this)
    }
}

Nothing interesting here, we just filling the screen with black color and than drawing the snake and apple. Let’s move forward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private fun processInput() {
    for (event in game.input.consumeEvents()) {
        when (event) {
            is Input.Event.KeyPressed -> {
                when (event.data.keyCode) {
                    KeyEvent.VK_UP -> snake.direction = Direction.UP
                    KeyEvent.VK_RIGHT -> snake.direction = Direction.RIGHT
                    KeyEvent.VK_DOWN -> snake.direction = Direction.DOWN
                    KeyEvent.VK_LEFT -> snake.direction = Direction.LEFT
                }
            }
        }
    }
}

Here we are analyzing the pressed keys and setting the snake direction according to user input.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private fun drawSnake(graphics: Graphics2D) {
    graphics.apply {
        color = Color.red

        snake.body.forEach { part ->
            fillRect(
                part.x * CELL_SIZE - CELL_SIZE,
                game.screenSize.height - part.y * CELL_SIZE,
                CELL_SIZE,
                CELL_SIZE
            )
        }
    }
}

private fun drawApple(graphics: Graphics2D) {
    graphics.apply {
        color = Color.green

        fillRect(
            apple.x * CELL_SIZE - CELL_SIZE,
            game.screenSize.height - apple.y * CELL_SIZE,
            CELL_SIZE,
            CELL_SIZE
        )
    }
}

Those methods are used to draw the game objects. As you can see we can just fill the rectangles with different colors to make a distinction between snake body and apples: snake body is green but apples are red.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
private fun placeApple() {
    var x = (1 + (Math.random() * WORLD_WIDTH)).toInt()
    var y = (1 + (Math.random() * WORLD_HEIGHT)).toInt()

    while (!isCellEmpty(x, y)) {
        if (x < WORLD_WIDTH) {
            x++
        } else {
            if (y < WORLD_HEIGHT) {
                x = 1
                y++
            } else {
                x = 1
                y = 1
            }
        }
    }

    apple = Apple(x, y)
}

private fun isCellEmpty(x: Int, y: Int) = snake.body.none { it.x == x && it.y == y }

This block can be a bit harder to understand. Basically, we need to find an empty cell to place a new apple here but the location cannot be predictable so we should start with a random point. If that point is empty - that’s it, but if it’s not we need to scan our game grid line by line in order to find an empty cell. In that case we should use the first empty cell we find while scanning.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private fun gameIsOver(): Boolean {
    if (snake.body.size == WORLD_WIDTH * WORLD_HEIGHT) {
        return true
    }

    snake.body.forEachIndexed { index, part ->
        if (index > 0 && part.x == snake.head.x && part.y == snake.head.y) {
            return true
        }
    }

    return false
}

What is a game over state? There are 2 possible outcomes:

  • winning (snake took all of the space)
  • losing (snake tried to eat itself)

That’s exactly what we are checking for.

Running The Game

Let’s run our game and try to play it. You should see the following picture:

Game scene

Don’t forget to uncomment screen transition on the MainMenuScene.

Conclusion

Our game is almost ready, we just need to add the “game over” scene and reflect on the things that we’ve implemented so far. We will do it in the next post.

This site doesn't have ads and the reasons are simple:

  • Most people don't want to see ads, that's not what they look for when they open web pages.
  • Ad scripts can track visitors, exposing private data to third parties.

If you found this post valuable and you wish to leave a tip, you can do it with Bitcoin:

34CXtg7c4Vbw8DZjAwFQVsrbu9eDEbTzbA