4. Creating Your First Game with Ebitengine

4. Creating Your First Game with Ebitengine

Watch the associated video on YouTube or MakerTube

Watch on YouTube

Introduction #

In this tutorial we will expand our Ebitengine application to handle user input and move a character around the screen.

The code for this tutorial is available here. Please consider donating if you find this tutorial helpful.

Origin Point #

The origin point is a fixed point of reference in relation to some geometry.

All images in Ebitengine have an origin point of (0, 0) at the top left.

When we draw an image onto the screen using Ebitengine, we must to specify some screen position to draw at.

This position specifies where to start drawing the image, beginning with its origin point.

In other words, the position on the screen specifies where the top-left-most pixel of the image will be drawn.

Tick Rate #

Ebitengine’s default tick rate is 60 ticks per second.

With the default tick rate, we handle any changes in user input and update the game state every 16.6 milliseconds.

If a user both presses and releases a key within the time between ticks, the application will not observe a key press.

Because of this, it’s important to set a high enough tick rate that all user input events are handled by the application.

Setting the tick rate too high will consume excessive system resources, and may lower the frame rate.

At high enough tick rates, the system may struggle to maintain the timing of each tick, even when sacrificing the frame rate. Even if the system is able to sustain the high tick rate, battery-powered devices will drain more quickly.

A tick rate of 100, in my experience, results in accurate and responsive input handling without using resources excessively.

Handling Keyboard Input #

When we want to detect whether or not a key is currently pressed, we call ebiten.IsKeyPressed.

When we want to detect whether or not a key is just starting to be pressed this frame, we call inpututil.IsKeyJustPressed.

The difference between the two is that IsKeyPressed always returns true every frame the key continues to be pressed, while IsKeyJustPressed only returns true the first frame the key press event is observed.

We can check whether a key has just been released by calling inpututil.IsKeyJustReleased.

The Ebitengine cheat sheet documents the available input handling packages and functions.

Simulating Gravity #

In this tutorial, we will move the player character up and down the screen, so we will calculate and store a Y velocity.

In Ebitengine, the coordinate (0, 0) is at the top left of the screen, and the Y value increases as we move down the screen.

Here we will simulate gravity by gradually increasng the player character’s Y velocity up to a maximum value:

// Update is where we update the game state.
func (g *game) Update() error {
    // ...

    // Apply gravity.
    maxFallSpeed := 0.8
    if g.playerVelocity < maxFallSpeed {
        g.playerVelocity += 0.015
        if g.playerVelocity > maxFallSpeed {
            g.playerVelocity = maxFallSpeed
        }
    }

Moving the Character #

Players may press the space bar key to cause the player character to jump (or flap its wings).

To accomplish this, on the first frame the key is pressed we set the character’s Y velocity to -1.

// Update is where we update the game state.
func (g *game) Update() error {
    // ...

    // Handle jump.
    if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
        g.playerVelocity = -1
    }
}

To apply the character’s velocity and move the character around, we add its velocity to its current position:

// Update is where we update the game state.
func (g *game) Update() error {
    // ...

    // Apply velocity.
    g.playerY += g.playerVelocity
}

Handling Collisions #

Each tick, we will check if the character collides with top or bottom edges of the screen.

The character image in this tutorial is 16 pixels wide and 16 pixels high.

The first row of the image will be drawn at the player’s Y position, and the bottom row of the image will be drawn at Y+16.

Therefore, we check if the player’s position is less than zero or greater than the screen height minus the image height:

// Update is where we update the game state.
func (g *game) Update() error {
    // ...

    // Handle screen edge collision.
    if g.playerY < 0 || g.playerY >= screenH-playerH {
        g.reset()
    }
}

Embedding Assets #

When we compile our program, a single executable binary file is produced.

To use assets such as image files with our program, we could distribute the files alongside the binary.

To make distribution easier, we will embed the files directly within the binary as a virtual filesystem instead.

In this tutorial, we define a variable named assetFS which contains all files and directories in the asset directory.

//go:embed asset
var assetFS embed.FS

Loading an Image #

To parse image data into a format Ebitengine understands, we must import the appropriate encoder/decoder packages.

Some common image encoder/decoder packages in the standard library include:

Note that when loading GIF images, because Ebitengine images are static, the loaded image will not be animated.

In this tutorial, we will import the image/png encoder/decoder package:

import (
    _ "image/png"
    // ...
)

Once we have imported the appropriate image loader package, we can parse the image data stored in an embedded file:

func loadImage(assetPath string) *ebiten.Image {
    f, err := assetFS.Open(assetPath)
    if err != nil {
        log.Panic(err)
    }

    img, _, err := image.Decode(f)
    if err != nil {
        log.Panic(err)
    }

    return ebiten.NewImageFromImage(img)
}

With the above function defined, we can load our player character image and initialize the playerImg field of game:

// initialize sets up the initial state of the game.
func (g *game) initialize() {
    // Load player image.
    g.playerImg = loadImage("asset/image/shrimp.png")

    // ...
}

Drawing an Image #

By default, Ebitengine clears the screen by filling it with black every frame right before Draw is called.

Each time our Draw method is called, we will draw the game onto the screen in its entirety.

When drawing an image onto the screen, we create a new DrawImageOptions instance and modify its GeoM field.

The default position of the GeoM field is (0, 0). The final position of GeoM will specify where to draw the image.

In this tutorial, we are translating (moving) the geometry matrix (GeoM) position by playerX and playerY.

If we were to create a DrawImageOptions and call op.GeoM.Translate(2, 5) twice, the result would be (4, 10).

// Draw is where we draw the game state.
func (g *game) Draw(screen *ebiten.Image) {
    // ...

    // Draw player.
    op := &ebiten.DrawImageOptions{}
    op.GeoM.Translate(g.playerX, g.playerY)
    screen.DrawImage(g.playerImg, op)
}

Stay tuned for the next tutorial, Adding Obstacles to Your Game.

Please consider donating if you found this tutorial helpful.