A Web Dev Tries Game Development

Now that big game publishers are struggling, triple A games are failing big time and indie devs are barely surviving I figured it is a good time to give game development a try.

They say don’t reinvent the wheel, so the first step is to choose a game engine. I played with Unity a little bit back in college, and I remember it being loved by the community. Ok.. never mind. Let’s give Godot a try then. Or maybe not. Unreal Engine it is. Well… that’s too steep for my web developer standards.

Ok then… we are left with only one choice.

We’ll build our game from scratch, using Go and this nice little dead simple 2D game engine - Ebitengine. So let’s not waste any more time and jump right into it.

Working with Go and Ebitengine

We’ll start by initializing a go project, add in our dependencies and open the main function.

go mod init game
go get github.com/hajimehoshi/ebiten/v2
go mod tidy

This is where we’ll set our window size, a window title, and we’ll start our main game loop.

func main() {
    ebiten.SetWindowSize(SCREEN_WIDTH, SCREEN_HEIGHT)
    ebiten.SetWindowTitle("Awesome Game")

    game := InitGame()

    if err := ebiten.RunGame(game); err != nil {
        log.Fatal(err)
    }
}

This is the core structure that runs continuously during the execution of a game. It’s responsible for updating the game state and rendering the visuals to the screen over and over again, usually at a consistent frame rate, also known as FPS.

The core game loop relies on two primary methods: Update and Draw. So let’s define these in a new Game struct.

type Game struct {
}

func (g *Game) Update() error {
    return nil
}

func (g *Game) Draw(s *ebiten.Image) {
}

The Update method is called every tick or frame and is responsible for updating the game state. This includes handling things like player input, object movement, physics, collision detection and so on. We’ll look at all these concepts in more detail in a second. For now just remember that the game’s internal logic is processed here.

The Draw method is called after Update to render the game onto the screen. This is where visual elements like sprites, backgrounds and UI are drawn. Remember that the Draw method should not modify the game state; it is purely for rendering.

Ok, this is all the theory you need to know. For the moment this code will simply render a blank screen on the page, but now is when all the fun begins.

The first real thing our game needs is a main character that can walk across the screen and fight enemies. Let’s go ahead and create a new player file. This will be a game object so it will expose the same Update and Draw methods.

type Player struct {
    pos    Vector2D[float64]
    speed  Vector2D[float64]
    size   Vector2D[int]

    sprite *ebiten.Image
}

func (p *Player) Update() error {
}

func (p *Player) Draw(screen *ebiten.Image) {
}

Of course, the character has an X and Y coordinate-based position and a defined size. But more importantly, it needs to be rendered on the screen, which means we need some proper visuals. For those blessed with artistic talent and an eye for beautiful things, tools like Photoshop or Aseprite can be used to create your own pixel art. For the rest of us, an existing asset pack from itch.io will have to suffice.

We’ll load the image in an init player function and then Draw it on the screen at the correct coordinates.

func InitPlayer(game *Game) *Player {
    sprite, _, _ := ebitenutil.NewImageFromFile("./assets/warrior.png")

    return &Player{
        pos:    Vector2D[float64]{x: 100, y: GROUND_LEVEL - GRID_SIZE},
        speed:  Vector2D[float64]{x: 0, y: 0},
        size:   Vector2D[int]{x: PLAYER_WIDTH, y: PLAYER_HEIGHT},
        sprite: sprite,
    }
}

func (p *Player) Draw(screen *ebiten.Image) {
    screen.DrawImage(p.sprite, &ebiten.DrawImageOptions{})
}

Now, when we run the code, we can finally see our hero on the screen. In web development, just getting something rendered on the client is considered a major success. You’d call it a day, and you’d probably create a whole new framework based on your implementation.

Sprites and Animations

However, in game dev we’re just getting started. Before tackling player movement, we need to address one big issue — our hero is pretty static. Right now, it just sits there on the screen, staring back at you. This is called an idle state, and even though no action is being performed, the character should still be animated to make them feel more alive. So, we’ll need to replace this static image with a sprite-based animation.

Sprites are essentially 2D images or frames that represent the character in different poses or actions. To animate our hero in an idle, running, fighting or jumping state, we’ll use a sprite sheet, which is a collection of these frames laid out in a grid. Each frame shows the character in a slightly different position, and by cycling through them quickly, we create the illusion of movement.

So let’s make some small adjustments to our player structure. We’ll store our sprite reference, an animation flag which will be linked to one of the 4 actions our player can perform, the number of available frames for each animation, and the current frame rendered on the screen.

const (
    IDLE    = 0
    WALK    = 1
    ATTACK  = 2
    JUMP    = 3
)

type Player struct {
    // ...
    sprite        *ebiten.Image
    animation     int
    currentFrame  int
    frameCount    int
}

Then, in the Draw method we’ll navigate to the correct pose in our grid, we’ll crop it out, and then render it on the screen.

func (p *Player) Draw(screen *ebiten.Image) {
    frameX := p.currentFrame * p.size.x
    frameY := p.animation * p.size.y

    frameRect := image.Rect(frameX, frameY, frameX+p.size.x, frameY+p.size.y)
    frame := p.sprite.SubImage(frameRect).(*ebiten.Image)

    op := &ebiten.DrawImageOptions{}
    op.GeoM.Translate(p.pos.x, p.pos.y)

    screen.DrawImage(frame, op)
}

Remember that the Draw function is called for every frame, but it shouldn’t modify internal state variables like the current frame or the current animation.

We’ll do these changes in the Update method. This method tends to get big, since it covers functionality like actions movement or positioning.

func (p *Player) Update() error {
    p.handleActions()
    p.handleMovement()
    p.updatePosition()
    p.handleAnimation()
    return nil
}

So we’ll extract related logic in its own method.

We’ll define the animation as IDLE, which points to the first row in the sprite grid and then, every 5 frames we’ll increase our current frame by one.

const TICK_SPEED = 5

func (p *Player) handleAnimation() {
    p.animation = IDLE
    p.tick++

    if p.tick%TICK_SPEED == 0 {
        p.tick = 0
        p.currentFrame = (p.currentFrame + 1) % p.frameCount
    }
}

This will give us a pretty natural idle animation. You could also update the current frame on every tick, but this means the character will go through 60 poses in the span of a second which looks more like a panic attack than an idle pose.

Player Movement

Next, let’s add in some interaction. In the handle movement method which is called from update we can listen to the user input and define a moving speed if the player presses one of the movement controls.

func (p *Player) handleMovement() {
    p.speed.x = 0

    if ebiten.IsKeyPressed(ebiten.KeyA) {
        p.speed.x = -WALK_SPEED
    } else if ebiten.IsKeyPressed(ebiten.KeyD) {
        p.speed.x = WALK_SPEED
    }
}

We are building a basic platformer so moving left will set a negative speed on the X axis while moving right will define a positive speed. More importantly however, the animation is set to walk, which is the second row in the sprite.

func (p *Player) handleAnimation() {
    // ... other code

    if ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyD) {
        p.animation = WALK
    }
}

We’ll update the character position based on the speed and then we can see this in action by running the game.

func (p *Player) handleAnimation() {
    // ... other code

    if ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyD) {
        p.animation = WALK
    }
}

The character is idle at first, and starts running when I’m pressing the D key. It looks like we also implemented a moon walking animation, because while we press the A keyword the character moves to the left but appears to run to the right. We can easily fix this by adding an is facing right flag which flips our sprite pose.

func (p *Player) handleMovement() {
    if ebiten.IsKeyPressed(ebiten.KeyA) {
        p.speed.x = -WALK_SPEED
        p.isFacingRight = false
    } else if ebiten.IsKeyPressed(ebiten.KeyD) {
        p.speed.x = WALK_SPEED
        p.isFacingRight = true
    }
}

func (p *Player) Draw(screen *ebiten.Image) {
    if !p.isFacingRight {
        op.GeoM.Scale(-1, 1)
    }
    op.GeoM.Translate(float64(int(p.size.x)), 0)
    screen.DrawImage(frame, op)
}

Next, let’s spice things up a little bit more and implement a fighting action for our character.

Handle Actions will also be called by the Update method. Here we’ll listen for the space key pressed event, and we’ll enter the player in the fight mode.

func (p *Player) handleActions() {
    if ebiten.IsKeyPressed(ebiten.KeySpace) && !p.isFightingAnimation {
        p.isFighting = true
        p.isFightingAnimation = true
        if !p.attackSound.IsPlaying() {
            p.attackSound.Rewind()
            p.attackSound.Play()
        }
    } else {
        p.isFighting = false
    }
}

There are two really interesting things here. First, we should add a sound effect for the sword swinging. We can do this by loading an mp3 file which is decoded and attached to a player.

sound, _ := os.Open("./assets/attack.mp3")
soundDecoded, _ := mp3.DecodeWithSampleRate(44100, attackSound)
attackSound, _ := game.audioContext.NewPlayer(attackSoundDecoded)

func (p *Player) handleActions() {
    if ebiten.IsKeyPressed(ebiten.KeySpace) && !p.isFightingAnimation {
        p.isFighting = true
        p.isFightingAnimation = true
        if !p.attackSound.IsPlaying() {
            p.attackSound.Rewind()
            p.attackSound.Play()
        }
    } else {
        p.isFighting = false
    }
}

Then, when space is pressed we’ll simply play the sound from the beginning.

More importantly however is that we need two flags to handle the fighting state and the fighting animation. Think of a sword swing. The whole action can take an entire second, but the actual contact between the sword and the enemy takes just a few milliseconds. So we’ll keep track of the actual contact via the is fighting flag, and we’ll use the is fighting animation flag to make sure the entire sword swing animation is played on the screen.

func (p *Player) handleAnimation() {
    // ...
    if p.isFightingAnimation {
        p.currentFrame = (p.currentFrame + 1)

        if p.currentFrame >= p.fightFrames {
            p.isFightingAnimation = false
            p.isFighting = false
            p.currentFrame = 0
        }
    }
}

Without this special use case our character will be rendered in the fighting pose for just a couple of frames, and then the logic will revert to the idle pose.

Physics and Collisions

Ok, now let’s tackle another core component in any game - physics. Up until this point we worked on the X coordinate. Now we’ll look at the Y coordinate by implementing a jump action and gravity.

Gravity is a game constant which will always pull the character down, while jump can be triggered via the W keyword and is a force which will gradually push our character up.

const GRAVITY = 16

func (p *Player) handleMovement() {
    if !p.isJumping && ebiten.IsKeyPressed(ebiten.KeyW) {
        p.jumpSpeed = JUMP_SPEED
        p.isJumping = true
    }

    if p.isJumping {
        p.jumpSpeed += (GRAVITY / 2)
        p.speed.y += p.jumpSpeed
    }
} 

The speed will decrease until it becomes 0 at the peak of the jump, and then gravity takes over and the character begins falling back on the ground. Of course, when the character reaches the ground, it stops jumping and will remain at that level.

const GRAVITY = 16

func (p *Player) handleMovement() {
    if !p.isJumping && ebiten.IsKeyPressed(ebiten.KeyW) {
        p.jumpSpeed = JUMP_SPEED
        p.isJumping = true
    }

    if p.isJumping {
        p.jumpSpeed += (GRAVITY / 2)
        p.speed.y += p.jumpSpeed
    }

    if p.speed.y > 0 && int(p.pos.y+p.size.y) >= GROUND_LEVEL {
        p.pos.y = float64(GROUND_LEVEL) - float64(p.size.y)
        p.speed.y = 0
        p.isJumping = false
    }
} 

Great! Now we’ve got a character that can walk around, jump, and even fight invisible enemies. Next, let’s dive into some basic level design to enhance the game world a little bit.

We’ll begin by adding a platform object which is a rigid body our character can jump on. It has a fixed position, a size, and the well known Draw method which simply paints it on the screen.

type Platform struct {
    pos  Vector2D[float64]
    size Vector2D[float64]
}

func (p *Platform) Draw(s *ebiten.Image) {
    platform := ebiten.NewImage(int(p.size.x), int(p.size.y))
    platform.Fill(color.RGBA(255, 255, 255, 255))

    op := &ebiten.DrawImageOptions{}
    op.GeoM.Translate(p.pos.x, p.pos.y)

    s.DrawImage(platform, op)
}

The new bit here is the collides with method which is our first incursion into collisions. Collisions are how we detect when two game objects interact with each other, like when a character lands on a platform or hits an enemy.

func (p *Platform) CollidesWith(player *Player) bool {
    isWithinHorizontalBounds := player.pos.x + player.size.x > p.pos.x &&
                                 player.pos.x < p.pos.x + p.size.x

    isFalling := player.speed.y > 0

    isTouchingPlatform := player.pos.y + float64(player.size.y) >= p.pos.y &&
                           player.pos.y + float64(player.size.y) - player.speed.y < p.pos.y

    return isFalling && isWithinHorizontalBounds && isTouchingPlatform
}

For platforms, we’ll simply check if the horizontal boundaries overlap while the player is falling and touching the platform.

So now we can run the game to properly test our jump implementation and the world gravity which takes effect when the character runs off the platform.

We left the most interesting feature for last. Let’s implement the boss fight.

First, I put my Photoshop skills to good use and came up with what I believe is an accurate depiction of the world’s biggest enemy. Any resemblance to reality is pure coincidence.

Then, we’ll create an enemy structure, with all the properties and behavior you are familiar with.

type Enemy struct {
    pos Vector2D[float64]
    size Vector2D[int]
    speed Vector2D[float64]
    patrolStart float64
    patrolEnd float64
    isFacingRight bool
    health int
}

The implementation will be rather naive, so the enemy will simply walk across the screen, and we’ll use a health flag to keep track of its alive state.

func (e *Enemy) Update() {
    if !e.isAlive() {
        return
    }

    if e.isFacingRight {
        e.pos.x += e.speed.x
        if e.pos.x >= e.patrolEnd {
            e.isFacingRight = false
        }
    } else {
        e.pos.x -= e.speed.x
        if e.pos.x <= e.patrolStart {
            e.isFacingRight = true
        }
    }
}

Then, in the player action handling method, when space is pressed, we’ll check if the character is colliding with any enemies, and then simply decrease the enemy health.

func (p *Player) handleActions() {
    if ebiten.IsKeyPressed(ebiten.KeySpace) && !p.isFightingAnimation {
        p.isFighting = true
        p.isFightingAnimation = true

        if !p.attackSound.IsPlaying() {
            p.attackSound.Rewind()
            p.attackSound.Play()
        }

        for _, enemy := range p.game.enemies {
            if enemy.CollidesWith(p) && enemy.isAlive() {
                enemy.health -= 1
            }
        }
    }
}

This is just scratching the surface of game development, and there are numerous ways in which this implementation can be extended and optimized. Let me know in the comments if you are interested in further exploring game dev or any other fields.

If you feel like you learned something, you should watch some of my youtube videos or subscribe to the newsletter.

Until next time, thank you for reading!