Bismillah.
This is the fourth article in the Forge series!
In the previous post, we learned how to add audio to our games using the Audio package. Today, we’ll take our interactivity to the next level by learning how to handle input in Forge.
Forge offers two main approaches to handling input events:
callbacks (event-driven) and polling (state-driven).
Both have their advantages and knowing when to use which will help you write cleaner, more responsive code.
Understanding Input in Forge ⚙️
Input handling is at the heart of every interactive application. Whether you’re making a platformer, a puzzle game, or a little experiment, you’ll be constantly reacting to user input.
Forge provides an input system that can be accessed via driver.Input.
You can either poll keys every frame or register callbacks for specific actions.
Let’s compare both methods quickly:
| Method | Description | When to Use | Example | Device |
| Polling | You manually check key states each frame IsKeyPressed() | Continuous movement or actions that need to update per frame | Character movement | Keyboard, Mouse |
| Callbacks | Forge calls your function when an event occurs SetKeyCallback() | Single actions or events | Menu navigation, quitting on ESC | Keyboard, Mouse |
In this tutorial, we’ll use polling to move two players on screen, one using WASD keys and another using the arrow keys. We’ll also use a callback to handle the Escape key to close our game window.
Unfortunately, I wasn’t able to show a complete example on how to use the mouse button polls and callbacks, we’ll keep that for another tutorial!
Project Setup 🧱
We’ll start from the ForgeStarterTemplate.
Clone it using:
git clone https://github.com/ForgeLeaf/ForgeStarterTemplate.git
cd ForgeStarterTemplate
Now open main.go and change the window configuration in the main() method:
func main() {
config := Forge.DefaultDesktopConfig()
config.Title = "Handling Input in Forge"
config.Width = 800
config.Height = 500
if err := Forge.RunSafe(&Game{}, config); err != nil {
panic(err)
}
}
We’ll also use a FitViewport with 8×5 world units for rendering and a regular sprite batch for drawing.
Let’s create our assets folder inside the project root and download the two sprites.
You can download the assets from here (Credits are listed below!)

Defining our Player Struct ⛹️♂️
We’ll create a new file called player.go and define our player logic there.
Each player will have:
- a position and velocity
- a speed value
- a texture
- key bindings for movement
We’ll also create three methods:
input(driver)for key input (polling)logic(delta)to update the positiondraw(batch)to draw the sprite
Here’s what the beginning of the file looks like:
type Player struct {
position mgl32.Vec2
velocity mgl32.Vec2
speed float32
texture *Graphics.Texture
keyUp, keyDown, keyLeft, keyRight Input.Key
}
Polling Input Each Frame ⌨️
Inside our input() method, we’ll reset the velocity and check each direction key:
func (player *Player) input(driver *Forge.Driver) {
player.velocity[0] = 0
player.velocity[1] = 0
if driver.Input.IsKeyPressed(player.keyUp) {
player.velocity[1]++
}
if driver.Input.IsKeyPressed(player.keyDown) {
player.velocity[1]--
}
if driver.Input.IsKeyPressed(player.keyLeft) {
player.velocity[0]--
}
if driver.Input.IsKeyPressed(player.keyRight) {
player.velocity[0]++
}
player.velocity.Normalize()
}
This method is called every frame, and we’ll later multiply velocity by speed * delta in logic().
Creating Our Game Scene 🧩
Now let’s create and open our game file: game.go
We’ll define our implementation of the game there (instead of Application in main.go), so go ahead and clean your main.go file and move the Application struct to the game.go file, and rename it to Game. It should look like this:
package main
import "github.com/ForgeLeaf/Forge"
type Game struct {}
func (game *Game) Create(driver *Forge.Driver) {
}
func (game *Game) Render(driver *Forge.Driver, delta float32) {
}
func (game *Game) Resize(driver *Forge.Driver, width, height float32) {
}
func (game *Game) Destroy(driver *Forge.Driver) {
}
We’ll first define our game objects in the struct:
batchshould be our main sprite batch for rendering.viewportshould be our main viewport of the world.goblinrepresents our first player.werewolfrepresents our second player.
I’ll save you the headache and tell you that it should like this:
type Game struct {
batch *Graphics.Batch
viewport *Viewports.FitViewport
goblin *Player
werewolf *Player
}
Don’t forget to import the packages github.com/ForgeLeaf/Forge/Graphics and github.com/ForgeLeaf/Forge/Graphics/Viewports and running go mod tidy to fetch them.
So, back to coding, we’ll create our batch, viewport, and players in the Create() method:
game.batch = Graphics.NewBatch(driver)
game.viewport = Viewports.NewFitViewport(8, 5, driver.Width, driver.Height)
Then we load both textures and create our players:
goblinTexture, _ := Graphics.NewTexture("assets/Goblin.png")
game.goblin = NewPlayer(1, 1, goblinTexture, Input.KeyW, Input.KeyS, Input.KeyA, Input.KeyD)
werewolfTexture, _ := Graphics.NewTexture("assets/Werewolf.png")
game.werewolf = NewPlayer(5, 1, werewolfTexture, Input.KeyUp, Input.KeyDown, Input.KeyLeft, Input.KeyRight)
The code won’t compile just yet! That’s because we haven’t created the NewPlayer method 🙃
Implementing the Player ⛹️♂️
Let’s go back to our player.go file, we’ve already implemented our input(driver) method, now, our logic method isn’t that hard to implement either:
func (player *Player) logic(delta float32) {
player.position[0] += delta * player.speed * player.velocity[0]
player.position[1] += delta * player.speed * player.velocity[1]
}
To break it down: We add the value of velocity * speed * delta to our current position for both the x-Axis and the y-Axis.
To see the player, we’ll create and implement a draw(batch) method:
func (player *Player) draw(batch *Graphics.Batch) {
batch.Draw(player.texture, player.position[0], player.position[1], 2, 2)
}
Basically, we just tell the batch to draw the player texture at the current player position and we set the width and height of the rendered rectangle to 2×2 world-units.
Now all that’s left is to implement our constructor, the NewPlayer method, which takes in:
xandyas initial coordinates/position.texturefor the player texturekeyUp,keyDown,keyLeft, andkeyRightas mappings for movement input keys.
We want all players to have the same speed, so we’ll skip it here for now.
Your method should look like this:
func NewPlayer(x, y float32, texture *Graphics.Texture, keyUp, keyDown, keyLeft, keyRight Input.Key) *Player {
player := &Player{}
player.position = mgl32.Vec2{x, y}
player.velocity = mgl32.Vec2{}
player.speed = 2
player.texture = texture
player.keyUp = keyUp
player.keyDown = keyDown
player.keyLeft = keyLeft
player.keyRight = keyRight
return player
}
Before saving the file, add the following import statement and run go mod tidy to fetch the required dependencies:
import (
"github.com/ForgeLeaf/Forge"
"github.com/ForgeLeaf/Forge/Graphics"
"github.com/ForgeLeaf/Forge/Input"
"github.com/go-gl/mathgl/mgl32"
)
Implementing our Game 🏄♀️
Now all that’s left is to implement the our game loop, handle resizing and destroy events, and fine-tuning our final game!
Let’s start with the resizing, we basically just need to update our viewport:
func (game *Game) Resize(driver *Forge.Driver, width, height float32) {
game.viewport.Update(width, height, true)
}
That’s done! Now, to our Destroy() method, we need to dispose the player textures and our main batch, which can easily be achievied using this snippet:
func (game *Game) Destroy(driver *Forge.Driver) {
game.batch.Dispose()
game.goblin.texture.Dispose()
game.werewolf.texture.Dispose()
}
And that’s done too! Now all that’s left is to implement our Render() method. We’ll implement it like a real production-ready game does it. A render-cycle should, in the same order:
- Handle the input
- Update the logic
- Draw the entities
And we’ll apply that exact same principal here. We’ve conveniently created the input, logic, and draw methods in our player.go, so it’s basically just a matter of connecting it to our game loop.
We first handle the input:
game.goblin.input(driver)
game.werewolf.input(driver)
Then, for our logic, we’ll first apply our viewport and then call our players’ logic method:
game.viewport.Apply(true)
game.goblin.logic(delta)
game.werewolf.logic(delta)
And for the final part, we’ll clear our screen, set the batch projection, begin the batch, draw the players, and end the batch:
gl.ClearColor(0, 0, 0, 1)
gl.Clear(gl.COLOR_BUFFER_BIT)
game.batch.SetProjection(game.viewport.GetCamera().Matrix)
game.batch.Begin()
game.goblin.draw(game.batch)
game.werewolf.draw(game.batch)
game.batch.End()
Finally, now, if you launch the game, it should look similar to this:

Cool right? We can already see our player! Let’s give the background a nice blue-ish color like our beautiful sky:
gl.ClearColor(0.52, 0.72, 0.83, 1)
And it should already look somewhat better:

But wait, what are these black boxes there? Let’s fix it!
Fixing the Black Boxes ⬛
In video game development, you’ll often come across drawing optimization techniques and you’ll hear the word “Blending” alot.
What is blending? It’s an advanced topic that we’ll keep for later but if you’re excited enough to read about it today, here’s a nice article I’d recommend.
Blending is the way of telling the renderer how to render stacked objects, for example: Should it have transparency? Or just ignore it? Should it do something different like clip the objects or complete remove the pixel or whatever.
There are many different blending configurations. For now, we’ll basically just enable and use the simplest blending configuration by adding this snippet of code at the end of our Create() method:
gl.Enable(gl.BLEND)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
And don’t forget to import the right go-gl package and to run go mod tidy to fetch it:
import "github.com/go-gl/gl/v3.3-core/gl"
Should already look much better:

But the sprites look blurry, why? Let’s fix that too!
Fixing the Blurry Textures
The GPU uses algorithms to scale textures and most game libraries and frameworks default to what’s called bilinear-filtering, a texture-filter that linearly interpolates between the pixel color values to find the most suitable color for a scaled texture.
So, if it’s the most suitable color, why does it look blurry you may ask? It’s because our assets are pixel-art and pixel-art prefers to look sharp in some way. Drawing pixel-art means you’re working with a small canvas size and scaling it to the 10x will result it textures looking too blurry.
The solution? It’s to use a different commonly used texture-filter called nearest-filtering, which works a bit differently and produced pixelated instead of blurred outputs.
Here’s a nice comparison I got from the LearnOpenGL forum:

Credits: https://learnopengl.com/Getting-started/Textures
So, when create our player textures, we use the provided SetFilter(min, mag) method to change the filter of our texture, for the goblin it’s:
goblinTexture.SetFilter(gl.NEAREST, gl.NEAREST)
and for our werewolf it’s:
werewolfTexture.SetFilter(gl.NEAREST, gl.NEAREST)
Run the game and it should look much, much better:

Adding an ESC-Callback 🚦
Lastly, to say that we used a key callback in Forge, we’ll tell our driver to stop the game whenever we press the escape-key (ESC), so go ahead to the end of your Create() method and add this snippet of code:
driver.Input.SetKeyCallback(func(key Input.Key, action Input.Action, modifiers Input.ModifierKey) {
if key == Input.KeyEscape && action == Input.ActionPress {
driver.Stop()
}
})
And that’s it! The application should quit whenever we press the escape-key on our keyboard 🎉🎉🎉
Let’s play a bit with our game, the final code should produce something similar to the showcase below:

The Final Code
main.go
package main
import (
"runtime"
"github.com/ForgeLeaf/Forge"
)
func init() {
runtime.LockOSThread()
}
func main() {
config := Forge.DefaultDesktopConfig()
config.Title = "Handling Input in Forge"
config.Width = 800
config.Height = 500
if err := Forge.RunSafe(&Game{}, config); err != nil {
panic(err)
}
}
player.go
package main
import (
"github.com/ForgeLeaf/Forge"
"github.com/ForgeLeaf/Forge/Graphics"
"github.com/ForgeLeaf/Forge/Input"
"github.com/go-gl/mathgl/mgl32"
)
type Player struct {
position mgl32.Vec2
velocity mgl32.Vec2
speed float32
texture *Graphics.Texture
keyUp Input.Key
keyDown Input.Key
keyLeft Input.Key
keyRight Input.Key
}
func NewPlayer(x, y float32, texture *Graphics.Texture, keyUp, keyDown, keyLeft, keyRight Input.Key) *Player {
player := &Player{}
player.position = mgl32.Vec2{x, y}
player.velocity = mgl32.Vec2{}
player.speed = 2
player.texture = texture
player.keyUp = keyUp
player.keyDown = keyDown
player.keyLeft = keyLeft
player.keyRight = keyRight
return player
}
func (player *Player) input(driver *Forge.Driver) {
player.velocity[0] = 0
player.velocity[1] = 0
// Vertical
if driver.Input.IsKeyPressed(player.keyUp) {
player.velocity[1]++
}
if driver.Input.IsKeyPressed(player.keyDown) {
player.velocity[1]--
}
// Horizontal
if driver.Input.IsKeyPressed(player.keyLeft) {
player.velocity[0]--
}
if driver.Input.IsKeyPressed(player.keyRight) {
player.velocity[0]++
}
player.velocity.Normalize()
}
func (player *Player) logic(delta float32) {
player.position[0] += delta * player.speed * player.velocity[0]
player.position[1] += delta * player.speed * player.velocity[1]
}
func (player *Player) draw(batch *Graphics.Batch) {
batch.Draw(player.texture, player.position[0], player.position[1], 2, 2)
}
game.go
package main
import (
_ "image/png"
"github.com/ForgeLeaf/Forge"
"github.com/ForgeLeaf/Forge/Graphics"
"github.com/ForgeLeaf/Forge/Graphics/Viewports"
"github.com/ForgeLeaf/Forge/Input"
"github.com/go-gl/gl/v3.3-core/gl"
)
type Game struct {
batch *Graphics.Batch
viewport *Viewports.FitViewport
goblin *Player
werewolf *Player
}
func (game *Game) Create(driver *Forge.Driver) {
game.batch = Graphics.NewBatch(driver)
game.viewport = Viewports.NewFitViewport(8, 5, driver.Width, driver.Height)
goblinTexture, _ := Graphics.NewTexture("assets/Goblin.png")
goblinTexture.SetFilter(gl.NEAREST, gl.NEAREST)
game.goblin = NewPlayer(1, 1, goblinTexture, Input.KeyW, Input.KeyS, Input.KeyA, Input.KeyD)
werewolfTexture, _ := Graphics.NewTexture("assets/Werewolf.png")
werewolfTexture.SetFilter(gl.NEAREST, gl.NEAREST)
game.werewolf = NewPlayer(5, 1, werewolfTexture, Input.KeyUp, Input.KeyDown, Input.KeyLeft, Input.KeyRight)
gl.Enable(gl.BLEND)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
driver.Input.SetKeyCallback(func(key Input.Key, action Input.Action, modifiers Input.ModifierKey) {
if key == Input.KeyEscape && action == Input.ActionPress {
driver.Stop()
}
})
}
func (game *Game) Render(driver *Forge.Driver, delta float32) {
// input
game.goblin.input(driver)
game.werewolf.input(driver)
// logic
game.viewport.Apply(true)
game.goblin.logic(delta)
game.werewolf.logic(delta)
// draw
gl.ClearColor(0.52, 0.72, 0.83, 1)
gl.Clear(gl.COLOR_BUFFER_BIT)
game.batch.SetProjection(game.viewport.GetCamera().Matrix)
game.batch.Begin()
game.goblin.draw(game.batch)
game.werewolf.draw(game.batch)
game.batch.End()
}
func (game *Game) Resize(driver *Forge.Driver, width, height float32) {
game.viewport.Update(width, height, true)
}
func (game *Game) Destroy(driver *Forge.Driver) {
game.batch.Dispose()
game.goblin.texture.Dispose()
game.werewolf.texture.Dispose()
}
Compare it to your version and tell me if it works! That’s it! You’ve successfully drawn your first texture with Forge. If you’ve faced any issues, please message me using the form below.
You can find the repository for this tutorial here: https://github.com/ForgeLeaf/HandlingInput-Forge
Asset Credits 🔗
🐺Werewolf.png was created by Stephen Challener (Redshrike), William Thompson (William.Thompsonj), & Jordan Irwin (AntumDeluge) and obtained from OpenGameArt.org.
💚Goblin.png was created by Stephen Challener (Redshrike) & William Thompson (William.Thompsonj) and obtained from OpenGameArt.org.
Common Issues
If you’re getting a black screen:
- Check if you imported the
image/pngpackage. - Check if you’re rendering within
batch.Begin()andbatch.End(). - Check if you’re applying the viewport projection to the batch.
If the code isn’t compiling:
- Check if you’ve misspelled any imports.
- Run
go mod tidyand rebuild. - Clear IDE cache.
- Check for syntax errors.
If your program is crashing before rendering anything:
- Check whether you’ve imported the correct
go-glversion or not (Correct one isgo-gl/gl/v3.3-core/gl).
If you’re facing any other issues, contact me below!



