Create a gist now

Instantly share code, notes, and snippets.

What would you like to do?
JavaScript Game Basics in (Literate) CoffeeScript http://macournoyer.com/game/

The game we're building is a simple side-scrolling racing game. The player's car is static on the screen, only the background is moving. The enemies are planes whom the player must dodge using the up and down arrow keys. If the player hits a plane, he dies. One point is given each time a plane goes off screen without touching the player.

Sprites

Our game is composed of sprites. Each sprite has a position (x, y), velocity (speed) and one image, or more if animated.

Images are loaded from the DOM using their CSS selector (imagesSelector). We use ID selectors (#name) for single images and class selectors (.name) for animated sprites composed of several images.

See the images used in the game.

class Sprite
  x: 0
  y: 0

  xVelocity: 0
  yVelocity: 0

  animationSpeed: 0.3

  imagesSelector: null

  constructor: ->
    @images = $(@imagesSelector)
    @image = @images[0]
    { @width, @height } = @image
    @imageIndex = 0 # current drawn image
    @animationSpeed = 0 if @images.length == 0 # No animation if only one image

The two main functions responsible for running the game are update and draw. Both functions are called at short intervals (60 times / second) while the game is running.

The update function is called first and updates the sprite position, and the image if animated.

  update: ->
    @x += @xVelocity
    @y += @yVelocity

    @image = @images[Math.floor(@imageIndex)]
    @imageIndex += @animationSpeed
    @imageIndex %= @images.length # Keep the index within range

Next, the sprite will draw its image on the canvas at the given position.

  draw: (canvas) ->
    canvas.drawImage(@image, @x, @y)

Each active sprite in the game will be stored in an array: game.sprites, which we'll initialize at the end. To add a sprite to the screen, we add it to this array. To remove it from the game, we remove it from the array.

  remove: ->
    i = game.sprites.indexOf(this)
    game.sprites.splice(i, 1)

One last thing our sprite must do is detect when it hits another object. This is called collision detection. The following is a very simple implementation checking if two rectangles intersect minus a margin.

  isColliding: (other) ->
    margin = @width / 3

    !(((@y + @height - margin) < (other.y)) ||
      (@y + margin > (other.y + other.height)) ||
      ((@x + @width - margin) < other.x) ||
      (@x + margin > (other.x + other.width)))

Backgrounds

Our first specialized sprites draw the backgrounds on the scene. To make the backgrounds seamless, we draw two images side by side to cover to whole canvas.

The first, Background, is the road and Midground represents the hills far away in the back. To achieve a parallax effect, the road will move faster. Both move from right to left, thus their horizontal velocity (xVelocity) will be negative.

class Background extends Sprite
  imagesSelector: '#background'

  # Moves to the left, giving the impression the (static) car is moving to the right.
  xVelocity: -6

  update: ->
    super
    # Once it goes out of screen (on the left), we start again from the right.
    @x = 0 if @x <= -@width

  draw: (canvas) ->
    super
    # Draw a second image next to the first one to make sure we cover all the canvas.
    canvas.drawImage(@image, @width + @x, @y)

class Midground extends Background
  imagesSelector: '#midground'

  # Moves slower than the background for a parallax effect.
  xVelocity: -0.3

The Player

The player's car is another sprite. This one animated by alternating the drawn image. It can only move up or down.

class Player extends Sprite
  imagesSelector: '.player'

  constructor: ->
    super
    @dieImages = $('.boom')

    # Initial position: left centred
    @x = 50
    @y = game.height / 2 - @height / 2

Here is where we control the player's position. We can move it up or down. Each movement will affect its vertical velocity.

  up: ->
    return if @dead
    @yVelocity = -10
  down: ->
    return if @dead
    @yVelocity = 10

On each update, we make sure the car stays within bounds.

  update: ->
    # Stop if it reaches the top
    if @y < 10
      @yVelocity = 0
      @y = 10

    # Stop if it reaches the bottom
    if @y > game.height - @height - 50
      @yVelocity = 0
      @y = game.height - @height - 50

    super

    # Decrease velocity gradually for a smooth ride
    @yVelocity /= 1.1 unless @yVelocity == 0

When the player dies, we replace the animation with a new set of explosion images.

  die: ->
    return if @dead
    @imageIndex = 0
    @images = @dieImages
    @dead = true

The Enemies

The "enemies" in the game are the planes. They are also animated with a series of images. They will come from the right and move to the left until going off screen to be removed.

If one hits the player, the player dies.

On each update we check if we hit the player or if we're out of screen.

class Plane extends Sprite
  imagesSelector: '.plane'

  constructor: ->
    super

    # Initial position: off the screen on the right. Random vertical position.
    @x = game.width
    @y = Math.floor(Math.random() * (game.height - 140) + 1)

  update: ->
    if @isColliding(game.player)
      game.player.die()

    # Remove plane once it goes off screen
    if @x + @width < 0
      # Give 1 point for each dodged plane.
      game.addPoints 1 unless game.player.dead
      @remove()

    super

Creation of the planes is handled by a fake sprite. It will not draw anything. Instead, it will create planes at interval.

To make the game more challenging, we increase the interval at which planes are added (addInterval) and also make them faster (planeVelocity) as time goes by.

class PlaneFactory
  planeVelocity: 10 # Starting velocity of planes
  addInterval: 200  # One plane created at each 200 turn in the game loop
  counter: 0        # Counts the turns in the game loop

  draw: ->

  update: ->
    # At each <addInterval> iterations, we add a plane.
    if @counter >= @addInterval
      @add()
      @counter = 0
      # Reduce interval to add more planes as time goes by.
      # Makes the game more difficult the longer you play.
      @addInterval /= 1.1 unless @addInterval <= 20
    else
      @counter += 1

  add: ->
    plane = new Plane
    plane.xVelocity = -@planeVelocity
    game.sprites.push plane

    # Make the next plane faster.
    @planeVelocity *= 1.01 unless @planeVelocity >= 30

The Pause Icon

We need one last sprite to draw an icon when the game is paused.

class PauseIcon extends Sprite
  imagesSelector: '#pause'

  constructor: ->
    super
    # Center it on the screen.
    @x = game.width / 2 - @width / 2
    @y = game.height / 2 - @height / 2

The Game Loop

Here is the game loop. Every game EVER is designed around a loop that runs at interval. On each turn in the loop, objects are updated and drawn on the screen. The drawing interval is calculated to match the screen refresh rate. We'll keep things simple and go with a static value of 60 full frame drawings (and updates) per second (FPS).

For a smarter loop that will run at an interval synced with the browser's rendering and GPU, use requestAnimationFrame to schedule a call to run().

However note that those two approaches would not offer a consistent gameplay speed across machines. If a machine can't keep up with the animations, the whole game will run slower. To fix, this you can implement fixed time steps by checking the clock on each loop run.

game =
  start: ->
    fps = 60
    @timer = setInterval =>
      @run()
    , 1000 / fps

Running one iteration of the game is a matter of updating each sprite and drawing them.

  run: ->
    @sprites.forEach (sprite) -> sprite.update()
    @sprites.forEach (sprite) => sprite.draw(@canvas)

We can also pause and restart the game.

  pause: ->
    if @running()
      @stop()
    else
      @start()

  restart: ->
    @stop()
    @create()
    @start()

  stop: ->
    # Draw the pause icon
    @pauseIcon.draw(@canvas)

    clearInterval @timer
    @timer = null

  running: ->
    @timer?

Putting It All Together

The sprites are created and assembled in the game.sprites (@sprites) array. We start from the farthest object since each one will draw on top of previous ones.

  create: ->
    @sprites = []
    @score = 0

    # Add the road
    @sprites.push new Background
    
    # Add the hills and three in the back
    @sprites.push new Midground

    # The car
    @sprites.push @player = new Player

    # The fake sprite creating planes
    @sprites.push new PlaneFactory

    # Init the pause icon
    @pauseIcon = new PauseIcon

To initialize the game, we get the canvas we'll be drawing on, create the sprites and hook to keyboard events.

  init: ->
    el = $("canvas")[0]
    @canvas = el.getContext("2d")
    @width = el.width
    @height = el.height

    @create()

    $("body").keydown (e) ->
      if e.keyCode in [37..40] # arrow keys
        if game.running()
          e.preventDefault()
        else
          return

      switch e.keyCode
        when 38 then game.player.up()
        when 40 then game.player.down()
        when 80 then game.pause() # p
        when 82 then game.restart() # r

    $("canvas").click ->
      game.pause()

    @run() # Run once to draw all the things
    @stop()

The score is displayed in a simple <p> tag. We could draw it inside the canvas using image font.

  addPoints: (points) ->
    @score += points
    $('#score').text "#{@score} points"

Finally, we wait for all images to load and initialize the game!

$ ->
  images = $('#images img')
  loaded = 0
  images.load ->
    loaded += 1
    if loaded == images.length
      game.init()

That's it! Happy playing :)

<!DOCTYPE html>
<html lang="en">
<head>
<script src="//code.jquery.com/jquery-2.0.3.min.js"></script>
<script src="game.js"></script>
<title>JavaScript Game Basics in (Literate) CoffeeScript</title>
</head>
<body>
<canvas id="canvas" width="1024" height="768"></canvas>
<div id="instructions">
<span class="pull-right"><strong>up:</strong> move up, <strong>down:</strong> move down,
<strong>p:</strong> pause, <strong>r:</strong> restart</span>
<strong id="score">0 points</strong>
</div>
<div id="images" style="display: none">
<h5>Background (#background)</h5>
<img id="background" src="http://macournoyer.com/game/images/graphics/ui_background.png">
<h5>Midground (#midground)</h5>
<img id="midground" src="http://macournoyer.com/game/images/graphics/ui_midground.png">
<h5>Pause Icon (#pause)</h5>
<img id="pause" src="http://macournoyer.com/game/images/ui_buttons/pause.png">
<h5>Player (.player)</h5>
<img class="player" src="http://macournoyer.com/game/images/players/character1.001.png">
<img class="player" src="http://macournoyer.com/game/images/players/character1.002.png">
<img class="player" src="http://macournoyer.com/game/images/players/character1.003.png">
<img class="player" src="http://macournoyer.com/game/images/players/character1.004.png">
<img class="player" src="http://macournoyer.com/game/images/players/character1.005.png">
<img class="player" src="http://macournoyer.com/game/images/players/character1.006.png">
<img class="player" src="http://macournoyer.com/game/images/players/character1.007.png">
<img class="player" src="http://macournoyer.com/game/images/players/character1.008.png">
<img class="player" src="http://macournoyer.com/game/images/players/character1.009.png">
<h5>Explosion when player dies (.boom)</h5>
<img class="boom" src="http://macournoyer.com/game/images/players/death_all/obstacle1_death0001.png">
<img class="boom" src="http://macournoyer.com/game/images/players/death_all/obstacle1_death0002.png">
<img class="boom" src="http://macournoyer.com/game/images/players/death_all/obstacle1_death0003.png">
<img class="boom" src="http://macournoyer.com/game/images/players/death_all/obstacle1_death0004.png">
<img class="boom" src="http://macournoyer.com/game/images/players/death_all/obstacle1_death0005.png">
<img class="boom" src="http://macournoyer.com/game/images/players/death_all/obstacle1_death0006.png">
<img class="boom" src="http://macournoyer.com/game/images/players/death_all/obstacle1_death0007.png">
<img class="boom" src="http://macournoyer.com/game/images/players/death_all/obstacle1_death0008.png">
<img class="boom" src="http://macournoyer.com/game/images/players/death_all/obstacle1_death0009.png">
<h5>Enemy (.plane)</h5>
<img class="plane" src="http://macournoyer.com/game/images/obstacles/obstacles_moving/obstacles20001.png">
<img class="plane" src="http://macournoyer.com/game/images/obstacles/obstacles_moving/obstacles20002.png">
<img class="plane" src="http://macournoyer.com/game/images/obstacles/obstacles_moving/obstacles20003.png">
<img class="plane" src="http://macournoyer.com/game/images/obstacles/obstacles_moving/obstacles20004.png">
<img class="plane" src="http://macournoyer.com/game/images/obstacles/obstacles_moving/obstacles20005.png">
<img class="plane" src="http://macournoyer.com/game/images/obstacles/obstacles_moving/obstacles20006.png">
<img class="plane" src="http://macournoyer.com/game/images/obstacles/obstacles_moving/obstacles20007.png">
<img class="plane" src="http://macournoyer.com/game/images/obstacles/obstacles_moving/obstacles20008.png">
<img class="plane" src="http://macournoyer.com/game/images/obstacles/obstacles_moving/obstacles20009.png">
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment