BOUNCE GAME

Introduction

This tutorial is designed to give an example of the basic structure of Nimgame2 applications. The following concepts will be explained here:

  • engine initialization
  • assets loading
  • scenes
  • texture graphics
  • text graphics
  • entity objects
  • colliders and collisions
  • keyboard input
  • pausing
  • sound

Assets

For any basic game you'll need three basic kinds of assets:

  • fonts
  • graphics
  • sounds

So, the assets directory of the game will be structured as shown here:

    ./data/
      |
      |-- fnt/
      |-- gfx/
      |-- sfx/

For rendering all text in the game we will use good old Fixedsys Excelsior (at "data/fnt").

We will need just two graphic assets here: the ball and the paddle (at "data/gfx").

Ball graphic asset Paddle graphic asset

As for the sounds, we will not worry about it for now.

Starting to Code

All source code will be placed into the "src" directory. Let's start with a file for storing constants and variables that might be accessed from different points of the game.

data.nim

import
  nimgame2 / [
    assets,
    scene,
    types,
  ]


const
  GameWidth* = 640
  GameHeight* = 360
  GameTitle* = "Nimgame 2 Bounce"


var
  titleScene*, mainScene*: Scene

As you can see, we will have two game scenes: title and main. We will create a souce file for each of them.

title.nim

import
  nimgame2 / [
    nimgame,
    scene,
    types,
  ],
  data


type
  TitleScene = ref object of Scene


proc init*(scene: TitleScene) =
  init Scene(scene)


proc free*(scene: TitleScene) =
  discard


proc newTitleScene*(): TitleScene =
  new result, free
  init result


method event*(scene: TitleScene, event: Event) =
  if event.kind == KeyDown:
    game.scene = mainScene

main.nim

import
  nimgame2 / [
    scene,
  ],
  data


type
  MainScene = ref object of Scene


proc init*(scene: MainScene) =
  init Scene(scene)


proc free*(scene: MainScene) =
  discard


proc newMainScene*(): MainScene =
  new result, free
  init result


method show*(scene: MainScene) =
  echo "Switched to MainScene"

And, finally, we will create a main source file:

bounce.nim

import
  nimgame2 / [
    nimgame,
    settings,
    types,
  ],
  data,
  title,
  main


game = newGame()
if game.init(GameWidth, GameHeight, title = GameTitle, integerScale = true):

  # Init
  game.setResizable(true) # Window could be resized
  game.minSize = (GameWidth, GameHeight) # Minimal window size
  game.windowSize = (GameWidth * 2, GameHeight * 2) # Double scaling (1280x720)
  game.centrify() # Place window at the center of the screen

  # Create scenes
  titleScene = newTitleScene()
  mainScene = newMainScene()

  # Run
  game.scene = titleScene # Initial scene
  run game  # Let's go!

During the process of development we will rebuild the game constantly, so let's create some basic scripts for that:

build.debug.sh

    #!/bin/sh
    cd src
    nim c --out:../ng2bounce bounce.nim
    cd ..

build.release.sh

    #!/bin/sh
    cd src
    nim c --out:../ng2bounce -d:release --opt:speed bounce.nim
    rm -rf nimcache
    cd ..

Or, if you are working on Windows:

build.debug.bat

    cd src
    nim c --out:..\ng2bounce.exe bounce.nim
    cd ..

build.release.bat

    cd src
    nim c --out:..\ng2bounce -d:release --opt:speed --app:gui bounce.nim
    rmdir /s /q nimcache
    cd ..

So, at this moment, our directory structure looks like this:

    ./ng2bounce/
      |
      |-- build.debug.sh
      |-- build.release.sh
      |
      |-- data/
      |     |
      |     |-- fnt/
      |     |     |
      |     |     |-- FSEX300.ttf
      |     |
      |     |-- gfx/
      |     |     |
      |     |     |-- ball.png
      |     |     |-- paddle.png
      |     |
      |     |-- sfx/
      |
      |-- src/
            |
            |-- bounce.nim
            |-- data.nim
            |-- main.nim
            |-- title.nim

When we run our "build.debug.sh" script, we should get an executalbe "ng2bounce" that just shows a black screen and outputs a "Switched to MainScene" message to the console when any keyboard key is pressed.

Title Screen

We will start with populating the title screen. There will be two text graphics: the title of the game and a subtitle hint under it.

To render any text we need to load our font first.

data.nim

import
  nimgame2 / [
    ...
    font,
    ...
    truetypefont,
    ...
  ]

...

var
  ...
  defaultFont*, bigFont*: TrueTypeFont


proc loadData*() =
  defaultFont = newTrueTypeFont()
  if not defaultFont.load("data/fnt/FSEX300.ttf", 16):
    echo "ERROR: Can't load font"
  bigFont = newTrueTypeFont()
  if not bigFont.load("data/fnt/FSEX300.ttf", 32):
    echo "ERROR: Can't load font"


proc freeData*() =
  defaultFont.free()
  bigFont.free()

We will call the loading procedure from our main file.

bounce.nim

...

loadData() # Call it before any scene initialization

# Create scenes
...

And finally, we will create our entities in TitleScene's init() procedure.

title.nim

import
  nimgame2 / [
    entity,
    font,
    ...
    textgraphic,
    ...
  ],
  ...

proc init*(scene: TitleScene) =
  ...

  # Create a title text graphic with a big font
  let titleText = newTextGraphic(bigFont)
  titleText.setText GameTitle # Set the text to render

  let title = newEntity() # Create a title entity
  title.graphic = titleText # Assign the title text graphic
  title.centrify() # Set the origin point to the graphic's center
  title.pos = (GameWidth / 2, GameHeight / 3) # Set the title position on the screen
  scene.add title # Add title entity to the scene

  # Create a subtitle text graphic with a default font
  let subtitleText = newTextGraphic(defaultFont)
  subtitleText.setText "Press any key to start" # Set the text

  let subtitle = newEntity() # Create a subtitle entity
  subtitle.graphic = subtitleText # Assign the subtitle text graphic
  subtitle.centrify() # Set the origin point to the graphic's center
  subtitle.pos = game.size / 2 # Place to the center of the screen
  scene.add subtitle # Add subtitle entity to the scene

  ...

After running the game we should see this screen:

TitleScene screenshot

Loading Assets

Though we only have two graphic assets now it is surelly an overkill to use an asset manager, but it is a good opportunity to give an example of its usage.

data.nim

import
  nimgame2 / [
    ...
    texturegraphic,
    ...
  ]

...

var
  ...
  gfxData*:Assets[TextureGraphic]


proc loadData*() =
  ...

  gfxData = newAssets[TextureGraphic](
    "data/gfx",
    proc(file: string): TextureGraphic = newTextureGraphic(file))


proc freeData*() =
  ...
  for graphic in gfxData.values:
    graphic.free()

As you can see, we pass two arguments to the asset manager constructor: a path to the assets and a procedure to load them. The procedure recieves a file name and should return an object to be loaded in our collection.

Note: If you try to load any directory containing other types of files, the loading procedure will throw an error.

Paddles

Let's start with creating paddle entity. This object will represent players in this game.

paddle.nim

import
  nimgame2 / [
    assets,
    graphic,
    input,
    nimgame,
    entity,
  ],
  data


const
  Speed = 250.0 # Speed (in pixels per second)


type
  PaddlePlacement* = enum
    ppLeft, ppRight

  PaddleControl* = enum
    pcPlayer1, pcPlayer2

  Paddle* = ref object of Entity
    control*: PaddleControl

As you can see, two enums represent the paddle position and how it will be controlled.

paddle.nim

...

proc init*(paddle: Paddle, placement: PaddlePlacement, control: PaddleControl) =
  paddle.initEntity()
  paddle.graphic = gfxData["paddle"]
  paddle.centrify() # Set the center point offset

  # Set position
  paddle.pos = case placement:
    of ppLeft: (paddle.graphic.w.float, game.size.h / 2)
    of ppRight: (float(game.size.w - paddle.graphic.w), game.size.h / 2)

  paddle.control = control # Set control mode


proc newPaddle*(placement: PaddlePlacement, control: PaddleControl): Paddle =
  new result
  result.init(placement, control)

As you see, for now we need to have only one param in our Paddle object — a control state, that we will need in the update() method.

paddle.nim

...

method update*(paddle: Paddle, elapsed: float) =
  var movement = Speed * elapsed

  # Read input
  case paddle.control:
  # First player
  of pcPlayer1:
    if ScancodeQ.down: paddle.pos.y -= movement # Move up
    if ScancodeA.down: paddle.pos.y += movement # Move down
  # Second player
  of pcPlayer2:
    if ScancodeUp.down: paddle.pos.y -= movement    # Move up
    if ScancodeDown.down: paddle.pos.y += movement  # Move down

  # Check for the screen borders
  paddle.pos.y = clamp(
    paddle.pos.y,
    paddle.center.y,
    game.size.h.float - paddle.center.y)

We calculate movement distance by multiplying the speed and time elapsed since previous update. Finally, don't forget to check for the screen borders.

Now we just need to add two Paddle entities into the main scene.

main.nim

import
  nimgame2 / [
    scene,
  ],
  data,
  paddle


type
  MainScene = ref object of Scene
    leftPaddle, rightPaddle: Paddle


proc init*(scene: MainScene) =
  init Scene(scene)

  # left paddle
  scene.leftPaddle = newPaddle(ppLeft, pcPlayer1)
  scene.add scene.leftPaddle

  # right paddle
  scene.rightPaddle = newPaddle(ppRight, pcPlayer2)
  scene.add scene.rightPaddle

...

Now the main scene should look like this:

Paddles screenshot

Colliders

To detect collisions between the ball and paddles we need to init colliders for these entities. All collider counstructors accept a parent entity reference, center point offset, and collider dimensions that depend on a particular collider type. We will use a simple box collider for our paddles.

paddle.nim

...

proc init*(paddle: Paddle, placement: PaddlePlacement, control: PaddleControl) =
  ...

  # Collisions
  paddle.tags.add "paddle"
  paddle.collider = paddle.newBoxCollider((0.0, 0.0), paddle.graphic.dim)
  paddle.collider.tags.add "ball"

...

The "paddle" and "ball" tags will be used to filter out the collisions.

Now we define a Ball entity the same way, but using the circle collider instead of a box one, passing the circle radius to its constructor.

ball.nim

import
  nimgame2 / [
    assets,
    entity,
    graphic,
    nimgame,
    types,
    utils,
  ],
  data


type
  Ball* = ref object of Entity
    radius: float


proc reset*(ball: Ball) =
  ball.pos = game.size / 2  # place to the center of the screen


proc init*(ball: Ball) =
  ball.initEntity()
  ball.graphic = gfxData["ball"]
  ball.radius = ball.graphic.dim.w / 2
  ball.centrify() # Set the center point offset
  ball.reset()

  # Collisions
  ball.tags.add "ball"
  ball.collider = ball.newCircleCollider((0.0, 0.0), ball.radius)
  ball.collider.tags.add "paddle"


proc newBall*(): Ball =
  new result
  result.init()

To be sure that we have initialized colliders correctly we could use a colliderOutline boolean variable from the "nimgame2/settings" module.

main.nim

import
  nimgame2 / [
    input,
    scene,
    settings,
  ],
  ball,
  ...


type
  MainScene = ref object of Scene
    ...
    ball: Ball


proc init*(scene: MainScene) =
  ...

  # ball
  scene.ball = newBall()
  scene.add scene.ball

...

method show*(scene: MainScene) =
  ...
  scene.ball.reset()

...

method update*(scene: MainScene, elapsed: float) =
  scene.updateScene(elapsed)

  if ScancodeF10.pressed: colliderOutline = not colliderOutline
  if ScancodeF11.pressed: showInfo = not showInfo

Now we could press F10 to toggle collider outlines. We also assigned the info panel toggle to F11 for good measure.

Collider outlines screenshot

Bounce

Now, as we have a ball entity, bounce logic could be implemented. The ball should bounce from top and bottom walls, and also from both paddles. Paddle bounces will increase ball's speed each time.

ball.nim

...

const
  Speed = 100.0 # Speed (in pixels per second)
  SpeedInc = 25.0 # Speed increase after each bounce

...

proc reset*(ball: Ball) =
  ball.pos = game.size / 2  # place to the center of the screen
  ball.vel.x = Speed * randomSign().float
  ball.vel.y = Speed * randomSign().float

...

method update*(ball: Ball, elapsed: float) =
  var movement = ball.vel * elapsed

  ball.pos += movement

  # Top and bottom walls collisions
  if ball.pos.y < ball.radius or
     ball.pos.y >= (game.size.h.float - ball.radius):
    ball.vel.y = -ball.vel.y


method onCollide*(ball: Ball, target: Entity) =
  if "paddle" in target.tags:
    # Check if the ball is in front of a paddle
    if (ball.pos.y >= target.pos.y - target.center.y) and
       (ball.pos.y <= target.pos.y + target.center.y):

      ball.vel.x = -ball.vel.x  # change horizontal direction

      # Move the ball out of the collision zone
      if ball.pos.x < game.size.w / 2:
        ball.pos.x = target.pos.x + target.center.x + ball.radius + 1
      else:
        ball.pos.x = target.pos.x - target.center.x - ball.radius - 1

      # increase speed
      ball.vel += (SpeedInc, SpeedInc) * ball.vel / abs(ball.vel)

Reset

Each time the ball leaves the screen it should be returned to the center and relaunched. It would be good to add a small pause before the launch too. This pause will be indicated by a white circle, shrinking as the pause runs out.

ball.nim

nimgame2 / [
  ...
  draw,
  ...
],
...

const
  ...
  Pause = 1.0 # Pause value before launch (in seconds)


type
  Ball* = ref object of Entity
    ...
    pause: float


proc reset*(ball: Ball) =
  ...
  ball.pause = Pause


method render*(ball: Ball) =
  ball.renderEntity()
  if ball.pause > 0.0: # pre-launch pause
    discard circle(ball.pos, ball.pause * 100, 0xFFFFFFFFF'u32, DrawMode.aa)


method update*(ball: Ball, elapsed: float) =
  if ball.pause <= 0.0:

    var movement = ball.vel * elapsed

    ball.pos += movement

    # Top and bottom walls collisions
    if ball.pos.y < ball.radius or
      ball.pos.y >= (game.size.h.float - ball.radius):
      ball.vel.y = -ball.vel.y

  else: # pre-launch pause

    ball.pause -= elapsed

...

The actual checking of the ball position will be performed in the main scene file.

main.nim

...

method update*(scene: MainScene, elapsed: float) =
  ...

  # check if the ball is out of the screen borders
  if (scene.ball.pos.x < 0) or (scene.ball.pos.x >= game.size.w.float) or
     (scene.ball.pos.y < 0) or (scene.ball.pos.y >= game.size.h.float):
    scene.ball.reset()

Score

At this moment we are ready to add a simple score counting into the game.

data.nim

...

var
  ...
  score1*, score2*: int

...

main.nim

import
  nimgame2 / [
    entity,
    graphic,
    ...
    textgraphic,
    ...
  ],
  ...

MainScene = ref object of Scene
  ...
  scoreText: TextGraphic


proc init*(scene: MainScene) =
  ...

  # score
  scene.scoreText = newTextGraphic(bigFont)
  scene.scoreText.setText "0:0"
  let score = newEntity()
  score.graphic = scene.scoreText
  score.centrify()
  score.pos = (game.size.w / 2, score.graphic.dim.h.float)
  score.layer = 10 # text should be over other entities
  scene.add score


proc free*(scene: MainScene) =
  scene.scoreText.free()

...

method show*(scene: MainScene) =
  ...
  score1 = 0
  score2 = 0
  scene.scoreText.setText "0:0"

method update*(scene: MainScene, elapsed: float) =
  ...
  # check if the ball is out of the screen borders
  if (scene.ball.pos.x < 0) or (scene.ball.pos.x >= game.size.w.float) or
     (scene.ball.pos.y < 0) or (scene.ball.pos.y >= game.size.h.float):
    # increase score
    if scene.ball.pos.x < 0:
      inc score2
    else:
      inc score1
    # update score graphic
    scene.scoreText.setText $score1 & ":" & $score2
    # reset
    scene.ball.reset()

Now your game screen should look like this:

Score counting screenshot

Pause

Adding the option to pause the game is actually pretty easy.

main.nim

  import
    nimgame2 / [
      ...,
      types,
    ],
    ...


type
  MainScene = ref object of Scene
    ...
    pause: Entity


proc init*(scene: MainScene) =
  ...

  # pause
  let pauseText = newTextGraphic(bigFont)
  pauseText.setText "PAUSE"
  scene.pause = newEntity()
  scene.pause.graphic = pauseText
  scene.pause.centrify()
  scene.pause.pos = game.size / 2
  scene.pause.visible = false
  scene.add scene.pause

...

method event*(scene: MainScene, event: Event) =
  scene.eventScene(event)
  if event.kind == KeyDown:
    # Pause/Unpause
    if event.key.keysym.scancode == ScancodeP:
      gamePaused = not gamePaused
      scene.pause.visible = gamePaused

...

Now pressing P will toggle pause mode.

Sound

Finally, we will add sound effects. WAV files will be placed into "data/sfx" directory:

  • bell.wav
  • hit1.wav
  • hit2.wav
  • hit3.wav

data.nim

import
  nimgame2 / [
    ...
    audio,
    ...
  ]

...

var
  ...
  sfxData*: Assets[Sound]


proc loadData*() =
  ...

  sfxData = newAssets[Sound](
    "data/sfx",
    proc(file: string): Sound = newSound(file))

...

proc freeData*() =
  ...

  for sound in sfxData.values:
    sound.free()

ball.nim

import
  nimgame2 / [
    ...
    audio,
    ...
  ],
  ...

...

method update*(ball: Ball, elapsed: float) =
    ...
    if ball.pos.y < ball.radius or
      ball.pos.y >= (game.size.h.float - ball.radius):
      ...
      discard sfxData["hit3"].play()

...

method onCollide*(ball: Ball, target: Entity) =
  ...
      if ball.pos.x < game.size.w / 2:
        ...
        discard sfxData["hit1"].play()
      else:
        ...
        discard sfxData["hit2"].play()
  ...

main.nim

import
  nimgame2 / [
    ...
    audio,
    ...
    ],
    ...

...

method show*(scene: MainScene) =
  ...
  discard sfxData["bell"].play()

...

method update*(scene: MainScene, elapsed: float) =
  ...
    # reset
    scene.ball.reset()
    discard sfxData["bell"].play()

What's Next?

Though the game is ready, there's a multitude of ways to improve it (like changing the angle of the ball's trajectory, for example). As this isn't directly related to the engine functionality, it is left for you to explore on your own.