PLATFORMER GAME

Introduction

Tihs tutorial is designed to give a basic example of a simple platformer-type game structure. The following concepts will be explained here:

  • buttons
  • tilemaps
  • tilemap colliders
  • group colliders
  • platformer physics
  • physics and logic procedures
  • scene camera

Assets

Apart from our usual need of fonts, graphic and sounds, we will need map data (in CSV) and GUI graphics. So here is our data directory structure:

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

For the text rendering we use Fixedsys Excelsior.

Game graphics consist of the tile sheet, player and enemy sprites.

Tile sheet graphic asset Player sprite graphic asset Enemy sprite graphic asset

To create menu buttons, we will need just one asset:

Button skin graphic asset

Sounds are located in the sfx directory and were created with the help of bfxr.net online tool.

Starting to Code

The first lines of code are actually pretty similar to the Tutorial 101 so we will not dive too deep here this time.

data.nim

import
  nimgame2 / [
    assets,
    audio,
    font,
    mosaic,
    scene,
    texturegraphic,
    truetypefont,
    types,
  ]


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


var
  titleScene*, mainScene*: Scene
  defaultFont*, bigFont*: TrueTypeFont
  gfxData*: Assets[TextureGraphic]
  sfxData*: Assets[Sound]
  buttonMosaic*: Mosaic
  buttonSkin*: TextureGraphic
  score*: int


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"

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

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

  buttonMosaic = newMosaic("data/gui/button.png", (8, 8))
  buttonSkin = newTextureGraphic()
  discard buttonSkin.assignTexture buttonMosaic.render(
    patternStretchBorder(8, 2))


proc freeData*() =
  defaultFont.free()
  bigFont.free()
  for graphic in gfxData.values:
    graphic.free()
  for sound in sfxData.values:
    sound.free()
  buttonSkin.free()
  buttonMosaic.free()

title.nim

import
  nimgame2 / [
    assets,
    entity,
    font,
    gui/button,
    gui/widget,
    mosaic,
    nimgame,
    scene,
    settings,
    textgraphic,
    texturegraphic,
    types,
  ],
  data


type
  TitleScene = ref object of Scene


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

  # Title text
  let titleText = newTextGraphic bigFont
  titleText.setText GameTitle
  let title = newEntity()
  title.graphic = titleText
  title.centrify()
  title.pos = (GameWidth / 2, GameHeight / 3)
  scene.add title


proc free*(scene: TitleScene) =
  discard


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


method event*(scene: TitleScene, event: Event) =
  scene.eventScene event
  if event.kind == KeyDown:
    case event.key.keysym.sym:
    of K_Space, K_Return:
      game.scene = mainScene # quick start
    else:
      discard

main.nim

import
  nimgame2 / [
    assets,
    entity,
    font,
    graphic,
    gui/button,
    gui/widget,
    nimgame,
    scene,
    textgraphic,
    types,
  ],
  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 event*(scene: MainScene, event: Event) =
  scene.eventScene event
  if event.kind == KeyDown:
    case event.key.keysym.sym:
    of K_F10:
      colliderOutline = not colliderOutline
    of K_F11:
      showInfo = not showInfo
    else: discard

platformer.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) # Doulbe scaling (1280x720)
  game.centrify() # Place window at the center of the screen
  background = 0x151B8D'u32

  loadData() # Call it before any scene initialization

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

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

Level

Here we will create a wireframe for our level module.

level.nim

import
  parseutils,
  nimgame2 / [
    assets,
    entity,
    texturegraphic,
    tilemap,
    utils,
  ],
  data


const
  TileDim* = (32, 32)


type
  Level* = ref object of TileMap


proc init*(level: Level, tiles: TextureGraphic) =
  init Tilemap level
  level.tags.add("level")
  level.graphic = tiles
  level.initSprite(TileDim)


proc newLevel*(tiles: TextureGraphic): Level =
  new result
  result.init(tiles)


proc load*(level: Level, csv: string) =
  level.map = loadCSV[int](
    csv,
    proc(input: string): int = discard parseInt(input, result))

  level.hidden.add @[8, 9, 10, 11]  # tiles on the third row are invisible markers
  level.passable.add @[0, 2, 3, 4, 8, 9, 10, 11] # tiles without colliders
  level.onlyReachableColliders = true # do not init unreachable colliders
  level.initCollider()

As you see, we are passing CSV data into the two-dimensional map sequence. The third row of tiles should not be visible as it is used for spawn selectors and such. All tiles, that the player should not be able to walk on, are added to the passable sequence. To speed up phyiscs we enabled the onlyReachableColliders option prior to calling the initCollider procedure.

Now we could add the level into the main scene.

main.nim

import
  ...
  data,
  level

...

type
  MainScene = ref object of Scene
    level: Level

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

  # Level
  scene.level = newLevel gfxData["tiles"]
  scene.level.load "data/csv/map1.csv"
  scene.level.layer = LevelLayer
  scene.add scene.level

You should see the top-left corner of the map on the main screen at the moment.

Player

The next step is to create a player entity with a couple of animations and the ability to spawn itself at player spawn selector (tile index 8).

player.nim

import
  nimgame2 / [
    entity,
    texturegraphic,
    tilemap,
    types,
  ],
  data,
  level


const
  Framerate = 1/12
  VisibilityDim: Dim = (w: 12, h: 10)
  Spawn = 8 # player spawn selector tile index
  PlayerRadius = 16
  PlayerSize = PlayerRadius * 2


type
  Player* = ref object of Entity
    level*: Level
    dying: bool


proc updateVisibility*(player: Player) =
  # update the visible portion of the map
  let center = player.level.tileIndex(player.pos)
  player.level.show = (
    x: (center.x - VisibilityDim.w)..(center.x + VisibilityDim.x),
    y: (center.y - VisibilityDim.h)..(center.y + VisibilityDim.h))


proc resetPosition*(player: Player) =
  # reset player position to a given tile
  player.pos = player.level.tilePos player.level.firstTileIndex(Spawn)


proc init*(player: Player, graphic: TextureGraphic, level: Level) =
  player.initEntity()
  player.tags.add "player"
  player.level = level
  player.graphic = graphic
  player.initSprite((PlayerSize, PlayerSize))
  discard player.addAnimation("right", [0, 1, 2, 3], Framerate)
  discard player.addAnimation("left", [0, 1, 2, 3], Framerate, Flip.horizontal)
  discard player.addAnimation("death", [4, 5, 6, 7], Framerate)


proc newPlayer*(graphic: TextureGraphic, level: Level): Player =
  new result
  result.init(graphic, level)


method update*(player: Player, elapsed: float) =
  player.updateEntity elapsed
  player.updateVisibility()

Adding it to the scene is trivial.

main.nim

import
  ...
  level,
  player

const
  LevelLayer = 0
  PlayerLayer = 10


type
  MainScene = ref object of Scene
    level: Level
    player: Player


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

  # Camera
  scene.camera = newEntity()
  scene.camera.tags.add "camera"
  scene.cameraBondOffset = game.size / 2  # set camera to the center

  ...

  # Player
  scene.player = newPlayer(gfxData["player"], scene.level)
  scene.player.collisionEnvironment = @[Entity(scene.level)]
  scene.player.layer = PlayerLayer
  scene.player.resetPosition()
  scene.add scene.player

  scene.cameraBond = scene.player # bind camera to the player entity
  scene.player.updateVisibility()

...

Player Movement

To make the player entity move, we will use the platformerPhysics procedure from the engine's entity module. As you could see from the previous section, we added the level as the only item in the player's collisionEnvironment sequence. This sequence is used by the platformerPhysics procedure to check if the player entity could move in any given direction.

player.nim

const
  ...
  ColliderRadius = PlayerRadius - 1
  GravAcc = 1000
  Drag = 400
  JumpVel = 450
  WalkVel = 750
  MaxVel = 350

...

proc init*(player: Player, graphic: TextureGraphic, level: Level) =
  ...


  # collider
  let c = newGroupCollider(player)
  player.collider = c
  # 1st collider
  c.list.add newCircleCollider(
    player,
    (PlayerRadius, PlayerRadius),
    ColliderRadius)
  # 2nd collider
  c.list.add newBoxCollider(
    player,
    (PlayerRadius, PlayerRadius + PlayerRadius div 2),
    (PlayerSize - 2, ColliderRadius))

  # physics
  player.acc.y = GravAcc
  player.drg.x = Drag
  player.physics = platformerPhysics

...

proc jump*(player: Player) =
  if player.vel.y == 0.0:
    player.vel.y -= JumpVel


proc right*(player: Player, elapsed: float) =
  if player.dying: return
  player.vel.x += WalkVel * elapsed
  if player.vel.x > MaxVel:
    player.vel.x = MaxVel
  if not player.sprite.playing and player.vel.y == 0.0:
    player.play("right", 1)


proc left*(player: Player, elapsed: float) =
  if player.dying: return
  player.vel.x -= WalkVel * elapsed
  if player.vel.x < -MaxVel:
    player.vel.x = -MaxVel
  if not player.sprite.playing and player.vel.y == 0.0:
    player.play("left", 1)

...

Next, we bind movement action to keyboard controls.

main.nim

...

method update*(scene: MainScene, elapsed: float) =
  scene.updateScene elapsed
  if ScancodeSpace.pressed:
    scene.player.jump()
  if ScancodeRight.down:
    scene.player.right(elapsed)
  if ScancodeLeft.down:
    scene.player.left(elapsed)

Dying and Spikes

You might have noticed that the spikes tile in our tile sheet is added to the passable sequence. The reason for this is that the platformerPhysics procedure prevents any collisions with the tile map before the standard collision callback could happen. That is why we should initialize any tiles, that you want player to interact with, as separate entities with their own colliders. So, for each "spikes" tile index in the map data we create a collidable entity (without any graphics) in its place.

main.nim

...

const
  ...
  Spikes = 4 # Spikes tile index

...

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

  # Spikes
  const
    SpikesOrigin = TileDim / 2 + (0, TileDim[1] div 4)
    SpikesDim = TileDim / (1, 2)
  for tileCoord in scene.level.tileIndex(Spikes):
    let e = newEntity()
    e.tags.add "spikes"
    e.pos = scene.level.tilePos(tileCoord)
    e.collider = newBoxCollider(e, SpikesOrigin, SpikesDim)
    e.collider.tags.add "player" # collide only with player entity
    e.parent = scene.camera
    scene.add e

  ...

Now we could use the standard collision callbacks in the player entity.

player.nim

...

proc die*(player: Player) =
  if not player.dying:
    player.dying = true
    player.play("death", 3)
    player.vel.y = -JumpVel


method update*(player: Player, elapsed: float) =
  ...

  if player.dying:
    if not player.sprite.playing:
      # reset
      player.play("right", 0)
      player.resetPosition()
      player.updateVisibility()
      player.dying = false
    else:
      return


method onCollide*(player: Player, target: Entity) =
  if "spikes" in target.tags:
    player.die()

Boxes and Coins

As you could imagine, any other interactive tiles are created similarly. Compare the following code to the previous section.

player.nim

...

type
  Player* = ref object of Entity
    ...
    requestCoins*: seq[CoordInt]

...

proc init*(player: Player, graphic, TextureGraphic, level: Level) =
  ...

  player.requestCoins = @[]


...

method onCollide*(player: Player, target: Entity) =
  ...

  if "box" in target.tags:
    let index = player.level.tileIndex(target.pos)
    player.level.tile(index) += 1 # red box -> grey box
    player.requestCoins.add index + (0, -1) # request coin spawn one tile higher
    target.dead = true

  if "coin" in target.tags:
    inc score
    target.dead = true

main.nim

...

const
  ...
  Box = 6     # Box tile index
  CoinA = 2   # Coin tile index (frame A)
  CoinB = 3   # Coin tile index (frame B)

...

proc spawnCoin*(scene: MainScene, index: CoordInt) =
  let e = newEntity()
  e.tags.add "coin"
  e.graphic = gfxData["tiles"]
  e.initSprite(TileDim)
  discard e.addAnimation("rotate", [2, 3], 1/8)
  e.play("rotate", -1) # continuous animation
  e.pos = scene.level.tilePos index
  e.collider = newCircleCollider(e, TileDim / 2 - 1, TileDim[0] / 2 - 1)
  e.collider.tags.add "player"
  e.parent = scene.camera
  scene.add e


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

  # Boxes
  const
    BoxPos1 = (2, TileDim[1] + 2)
    BoxPos2 = (TileDim[0] - 2, TileDim[1] + 2)
  for tileCoord in scene.level.tileIndex(Box):
    let e = newEntity()
    e.tags.add "box"
    e.pos = scene.level.tilePos(tileCoord)
    e.collider = newLineCollider(e, BoxPos1, BoxPos2)
    e.collider.tags.add "player" # collide only with player entity
    e.parent = scene.camera
    scene.add e

  # Coins
  for value in [CoinA, CoinB]:
    for tileCoord in scene.level.tileIndex(value):
      scene.level.tile(tileCoord) = 0
      scene.spawnCoin(tileCoord)

  ...

...

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

  # Spawn coins
  while scene.player.requestCoins.len > 0:
    scene.spawnCoin scene.player.requestCoins.pop()

Enemy

Our enemies should be able to move left and right (though not falling off the ledges) and kill the player entity on collision. The walking logic is implemented in the custom enemyLogic procedure.

We do not want physics and logic to be updated while the enemy is off the screen, so we do not call the updateEntity procedure this time, checking the enemy's position and calling procedures manually instead.

enemy.nim

import
  nimgame2 / [
    entity,
    graphic,
    texturegraphic,
    tilemap,
    types,
  ],
  data,
  level


const
  GravAcc = 1000
  WalkVel = 50


type
  Enemy* = ref object of Entity
    level*: Level
    prevVel: float


proc right*(enemy: Enemy) =
  enemy.vel.x = WalkVel


proc left*(enemy: Enemy) =
  enemy.vel.x -= WalkVel


proc enemyLogic(enemy: Entity, elapsed: float) =
  let enemy = Enemy enemy

  # check for pits
  if enemy.vel.x != 0.0:
    let
      pos = enemy.pos + enemy.graphic.dim * 0.5 *
        (if enemy.vel.x < 0: -1.0 else: 1.0)
      aheadIdx = enemy.level.tileIndex(pos) + (0, 1)
    if enemy.level.tile(aheadIdx) in enemy.level.passable:
      enemy.vel.x = 0.0

  # change direction
  if enemy.vel.x == 0.0:
    if enemy.prevVel <= 0.0:
      enemy.vel.x = WalkVel
      enemy.prevVel = WalkVel
    else:
      enemy.vel.x = -WalkVel
      enemy.prevVel = -WalkVel


proc init*(enemy: Enemy, graphic: TextureGraphic, level: Level) =
  enemy.initEntity()
  enemy.tags.add "enemy"
  enemy.level = level
  enemy.graphic = graphic
  enemy.centrify(ver = VAlign.top)
  enemy.logic = enemyLogic

  # collider
  let c = newPolyCollider(enemy, points = [(-15.0, 15.0), (0, 0), (16, 15)])
  c.tags.add "level"
  enemy.collider = c

  # physics
  enemy.acc.y = GravAcc
  enemy.fastPhysics = true
  enemy.physics = platformerPhysics


proc newEnemy*(graphic: TextureGraphic, level: Level): Enemy =
  new result
  result.init(graphic, level)


method update*(enemy: Enemy, elapsed: float) =
  # updateEntity override
  let index = enemy.level.tileIndex(enemy.pos)
  # physics and logic only if visible
  if index.x in enemy.level.show.x and index.y in enemy.level.show.y:
    enemy.logic(enemy, elapsed)
    enemy.physics(enemy, elapsed)

Enemy spawning is pretty similar to spikes and coins creation.

main.nim

import
  ...
  enemy,
  ...

const
  ...
  EnemySpawn = 9  # Enemy spawn tile index


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

  # Enemy
  for tileCoord in scene.level.tileIndex(EnemySpawn):
    let e = newEnemy(gfxData["enemy"], scene.level)
    e.collisionEnvironment = @[Entity(scene.level)]
    e.pos = scene.level.tilePos(tileCoord) + TileDim[1] / 2
    e.parent = scene.camera
    scene.add e

  ...

player.nim

...

method onCollide*(player: Player, target: Entity) =
  if "spikes" in target.tags or "enemy" in target.tags:
    player.die()

  ...

Victory

The victory is achieved when the player entity has collided with "finish" tile, or, more specifically, with an invisible collidable entity that was spawned in this tile.

Note, that we are fetching the index with the firstTileIndex procedure. It means that if there is more than one appearance of this tile index on a map, only one will be processed. If you want to have multiple finish points, change it to the tileIndex iterator.

main.nim

...

const
  ...
  UILayer = 20
  ...
  Finish = 11

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

...

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

  # Finish
  let
    finishIdx = scene.level.firstTileIndex(Finish)
    f = newEntity()
  f.tags.add "finish"
  f.collider = newCircleCollider(f, TileDim / 2, TileDim[0] / 2 - 1)
  f.pos = scene.level.tilePos(finishIdx)
  f.parent = scene.camera
  scene.add f

  # Victory
  let victoryText = newTextGraphic bigFont
  victoryText.setText "VICTORY!"
  scene.victory = newEntity()
  scene.victory.graphic = victoryText
  scene.victory.centrify(ver = VAlign.top)
  scene.victory.visible = false
  scene.victory.layer = UILayer
  scene.victory.pos = (GameWidth / 2, 0.0)
  scene.add scene.victory

  ...

...

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

 # Check for victory
 if scene.player.won:
   scene.victory.visible = true

For simplicity, the victory is represented with a simple text entity. You could try to implement a separate victory scene on your own for practice.

player.nim

...

type
  Player* = ref object of Entity
    ...
    won*: bool
    ...

...

method onCollide*(player: Player, target: Entity) =
  ...

  if "finish" in target.tags:
    player.won = true

Score

Finally, we add the score indicator, which is a simple text graphic updated with the data from the score variable. Note that we do not select scene.camera as its parent, so this entity is located constantly at the same position on the game screen.

main.nim

...
  type
    MainScene = ref object of Scene
      ...
      score: TextGraphic
      ...

...

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

  # Score
  let score = newEntity()
  scene.score = newTextGraphic defaultFont
  scene.score.setText "SCORE: 0"
  score.graphic = scene.score
  score.layer = UILayer
  score.pos = (12, 8)
  scene.add score

  ...

...

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

  # Update score
  scene.score.setText "SCORE: " & $score

  ...

Sounds

You could set the global sound volume in the initialization routine. It could also be changed in runtime, if you plan to add game settings controls later.

platformer.nim

import
  nimgame2 / [
    audio,
    ...

...

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

  # Init
  ...
  setSoundVolume Volume.high div 2 # set sound volume to a 50%

  ...

As we loaded our sounds into the sfxData assets collection, playing them at the right moment is trivial.

player.nim

import
  nimgame2 / [
    assets,
    audio,
    ...

...

proc jump*(player: Player) =
  ...
  if player.vel.y == 0.0:
    ...
    discard sfxData["jump"].play()

...

proc die*(player: Player) =
  ...
  discard sfxData["death"].play()

...

method onCollide*(player: Player, target: Entity) =
  ...
  if "box" in target.tags:
    ...
    discard sfxData["box"].play()

  if "coin" in target.tags:
    ...
    discard sfxData["pickup"].play()

  if "finish" in target.tags:
    if not player.won:
      discard sfxData["victory"].play()
    player.won = true

Reset

Finally, we will add the option to return to the title screen, resetting the level.

main.nim

...

proc init*(scene: MainScene) =
  ...
  if scene.level.map.len == 0: scene.level.load "data/csv/map1.csv"
  # changed from: scene.level.load "data/csv/map1.csv"
  ...

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

method show*(scene: MainScene) =
  hideCursor()
  init scene

...

As you can see, we moved the init call from the constructor to the show method, so the scene will be rebuilt each time it is switched to. Nim's GC will take care of previously allocated resources.

What's Next?

To practice your skills and improve the game, you could try adding the game settings screen, victory animation, leaderboard, different types of hazards and enemies, multiple levels, etc.