Skip to content

Instantly share code, notes, and snippets.

@jonny-novikov
Created January 28, 2020 14:18
Show Gist options
  • Save jonny-novikov/fcbd61edcecdae7e78fd666f8b1be875 to your computer and use it in GitHub Desktop.
Save jonny-novikov/fcbd61edcecdae7e78fd666f8b1be875 to your computer and use it in GitHub Desktop.

Tver.IO Makers Meetup

When a player starts the game, we commit a #world, a #player, and some #obstacles. These will keep all of the essential state of the game. All of this information could have been stored on the world, but for clarity we break the important bits of state into objects that they effect.

  • The #world tracks the distance the player has travelled, the current game screen, and the high score.
  • The #player stores his current y position and (vertical) velocity.
  • The obstacles have their (horizontal) offset and gap widths. We put distance on the world and only keep two obstacles; rather than moving the player through the world, we keep the player stationary and move the world past the player. When an obstacle goes off screen, we will wrap it around, update the placement of its gap, and continue on.

Setup

Add a flappy eve and a world for it to flap in:

commit
  [#player #self name: "eve" x: 25 y: 50 velocity: 0]
  [#world screen: "menu" tick: 0 distance: 0 best: 0 gravity: -0.061]
  [#obstacle gap: 35 offset: 0]
  [#obstacle gap: 35 offset: -1]

  [#time #system/timer resolution: 16.66666666667]

Next we draw the backdrop of the world. The player and obstacle will be drawn later based on their current state. Throughout the app we use resources from [@bhauman's flappy bird demo in clojure][1]. Since none of these things change over time, we commit them once when the player starts the game.

Draw the game world!

search
  world = [#world]

commit
  world <- [#ui/div style: [user-select: "none" -webkit-user-select: "none" -moz-user-select: "none"]  children:
    [#svg/root #game-window viewBox: "10 0 80 100", width: 480 children:
      [#svg/rect x: 0 y: 0 width: 100 height: 53 fill: "rgb(112, 197, 206)" sort: 0]
      [#svg/image x: 0 y: 52 width: 100 height: 43 preserveAspectRatio: "xMinYMin slice" href: "https://cdn.rawgit.com/bhauman/flappy-bird-demo/master/resources/public/imgs/background.png" sort: 1]
      [#svg/rect x: 0 y: 95 width: 100 height: 5 fill: "rgb(222, 216, 149)" sort: 0]]]

Game menus

These following blocks handle drawing the game's other screens (such as the main menu and the game over scene).

The main menu displays a message instructing the player how to start the game.

search
  [#world screen: "menu"]
  svg = [#game-window]

bind
  svg.children += [#svg/text x: 50 y: 45 text-anchor: "middle" font-size: 6 text: "Click the screen to begin!" sort: 10]

The "game over" screen displays the final score of the last game, the high score of all games, and a message inviting the player to play the game again.

search
  [#world screen: "game over" score best]
  svg = [#game-window]

bind
  svg.children += [#svg/text x: 50 y: 30 text-anchor: "middle" font-size: 6 text: "Game Over :(" sort: 10]
  svg.children += [#svg/text x: 50 y: 55 text-anchor: "middle" font-size: 6 text: "Score {{score}}" sort: 10]
  svg.children += [#svg/text x: 50 y: 65 text-anchor: "middle" font-size: 6 text: "Best {{best}}" sort: 10]
  svg.children += [#svg/text x: 50 y: 85 text-anchor: "middle" font-size: 4 text: "Click to play again!" sort: 10]

Score calculation

We haven't calculated the score yet, so let's do that. We calculate the score as the floor of the distance, meaning we just round the distance down to the nearest integer. If the distance between pipes is changed, this value can be scaled to search.

search
  world = [#world distance]

bind
  world.score := math/floor[value: distance]

Start a new game

When the game is on the "menu" or "game over" screens, a click anywhere in the application will (re)start the game. Additionally, if the current score is better than the current best, we'll swap them out now. Along with starting the game, we make sure to reset the distance and player positions in the came of a restart.

search
  world = [#world]
  [#html/event/click]
  ok = if world.screen = "menu" then "true"
       else if world.screen = "game over" then "true"

  new = if world.score > world.best then world.score
        else world.best
  player = [#player]

commit
  world.screen := "game"
  world.distance := 0
  world.best := new
  player.y := 50
  player.velocity := 0

Drawing

Player

Next we draw the #player at its (x,y) coordinates. Since the player is stationary in x, setting his x position here dynamically is just a formality, but it allows us to configure his position on the screen when we initialize. We create the sprite first, then set the x and y positions to let us reuse the same element regardless of where the player is.

Draw the player

search
  svg = [#game-window]
  player = [#player x y]

bind
  sprite = [#svg/image player | width: 10 height: 10 href: "http://i.imgur.com/sp68LtM.gif" sort: 8]
  sprite.x := x - 5
  sprite.y := y - 5
  svg.children += sprite

Obstacles

Drawing obstacles is much the same process as drawing the player, but we encapsulate the sprites into a nested SVG to group and move them as a unit.

Draw the obstacles

search
  svg = [#game-window]
  obstacle = [#obstacle x height gap]
  bottom-height = height + gap
  imgs = "https://cdn.rawgit.com/bhauman/flappy-bird-demo/master/resources/public/imgs"

bind
  sprite-group = [#svg/element tagname: "svg" #obs-spr obstacle | sort: 2 overflow: "visible" children:
    [#svg/image obstacle y: 0 width: 10 height, preserveAspectRatio: "none" href: "{{imgs}}/pillar-bkg.png" sort: 1]
    [#svg/image obstacle x: -1 y: height - 5 width: 12 height: 5 href: "{{imgs}}/lower-pillar-head.png" sort: 2]
    [#svg/image obstacle y: bottom-height width: 10 height: 90 - bottom-height, preserveAspectRatio: "none" href: "{{imgs}}/pillar-bkg.png" sort: 1]
    [#svg/image obstacle x: -1 y: bottom-height width: 12 height: 5 href: "{{imgs}}/lower-pillar-head.png" sort: 2]]
  sprite-group.x := x
  svg.children += sprite-group

Game Logic

Now we need some logic to actually play the game. We slide obstacles along proportional to the distance travelled, and wrap them around to the beginning once they're entirely off screen. Additionally, we only show obstacles once their distance travelled is positive. This allows us to offset a pipe in the future, without the modulo operator wrapping it around to start off halfway through the screen.

Obstacles

Every 2 distance a wild obstacle appears

search
  [#world distance]
  obstacle = [#obstacle offset]
  obstacle-distance = distance + offset
  obstacle-distance >= 0

bind
  obstacle.x := 100 - (50 * math/mod[value: obstacle-distance, by: 2])

When the obstacle is offscreen (x > 90), we randomly adjust the height of its gap to ensure the game doesn't play the same way twice. Eve's current random implementation yields a single result per seed per evaluation, so you can ask for random[seed: "foo"] in multiple queries and get the same result in that evaluation. In practice, this means that for every unique sample of randomness you care about in a program at a fixed time, you should use a unique seed. In this case, since we want one sample per obstacle, we just use the obstacle UUIDs as our seeds. The magic numbers in the equation just keep the gap from being at the very top of the screen or underground.

Readjust the height of the gap every time the obstacle resets

search
  [#time tick]
  [#world screen: "game" tick != tick]
  obstacle = [#obstacle x > 98]
  height = random/number[seed: tick] * 30 + 5

commit
  obstacle.height := height

Flapping the player

When a player clicks during gameplay, we give the bird some lift by setting its velocity.

search
  [#html/event/click]
  [#world screen: "game"]
  player = [#player #self]

commit
  player.velocity := 1.17

Scroll the world

Next, we scroll the world in time with tick updates. Eve is currently locked to 60fps updates here, but this will probably be configurable in the future. Importantly, we only want to update the world state once per tick, so to ensure that we note the offset of the tick we last computed in world.tick and ensure we’re not recomputing for the same offset.

search
  [#time tick]
  world = [#world screen: "game" tick != tick gravity]
  player = [#player y velocity]
  not([#html/event/click])

commit
  world.tick := tick
  world.distance := world.distance + 1 / 60
  player.y := y - velocity
  player.velocity := velocity + gravity

Collision

Checking collision with the ground is very simple. Since we know the y height of the ground, we just check if the player's bottom (determined by center + radius) is below that point.

The game is lost if the player hits the ground.

search
  world = [#world screen: "game"]
  [#player y > 85] // ground height + player radius

commit
  world.screen := "game over"

Collision with the pipes is only slightly harder. Since they come in pairs, we first determine if the player is horizontally in a slice that may contain pipes and if so, whether we're above or below the gap. If neither, we're in the clear, otherwise we've collided.

The game is lost if the player hits an #obstacle

search
  world = [#world screen: "game"]
  [#player x y]
  [#obstacle x: obstacle-x height gap]
  ∂x = math/absolute[value: obstacle-x + 5 - x] - 10 // distance between the edges of player and obstacle (offset of 1/2 obstacle width because origin is on the left)
  ∂x < 0
  collision = if y - 5 <= height then true
              else if y + 5 >= gap + height then true

commit
  world.screen := "game over"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment