The biggest architectural mistake you can make when building a game engine is letting the game know how it is being drawn.
When I started building the Derelict Facility engine, the output target was a raw ANSI terminal. The engine calculated A* paths, resolved line-of-sight, and then spewed escape codes (\033[31m) to os.Stdout.
Eventually, I hit the physical limits of terminal emulators: inconsistent character widths (especially for emojis), slow double-buffering, and a hard cap on frame rates. I needed to move to a real, hardware-accelerated graphics library like Raylib.
Because I had architected the engine around Go interfaces, I replaced the entire visual and input layer of the game without modifying a single line of the core simulation.
The Display Boundary
If your game logic calls raylib.DrawRectangle(), your game logic is permanently bound to Raylib.
To prevent this, I defined a strict boundary. The Engine struct knows absolutely nothing about ANSI, terminals, OS windows, or GPUs. It only knows about an interface called Display:
type Display interface {
Init(gridWidth, gridHeight int, title string) error
Close()
ShouldClose() bool
BeginFrame()
EndFrame()
Clear(colorHex uint32)
DrawText(gridX, gridY int, text string, colorHex uint32)
PollInput() []core.InputEvent
}
This interface enforces two critical constraints:
- Coordinate Abstraction: The engine asks to draw text at
(gridX: 10, gridY: 5). It does not know or care if that translates toRow 5, Column 10in a terminal orPixel X: 120, Pixel Y: 120on a 4K monitor. - Color Abstraction: The engine asks for a standard
uint32hex color (0xFF0000FF). It is the Display’s job to figure out how to render red.
Implementing the Raylib Concrete
With the boundary defined, implementing the Raylib backend was an isolated exercise. The concrete RaylibDisplay struct simply translates the abstract grid commands into Raylib-specific hardware calls.
type RaylibDisplay struct {
CellWidth int32
CellHeight int32
FontSize int32
FontPath string
Font rl.Font
}
func (r _RaylibDisplay) DrawText(gridX, gridY int, text string, colorHex uint32) {
// 1. Translate Grid coordinates to Pixel coordinates
pixelY := int32(gridY) _ r.CellHeight
colOffset := 0
for _, char := range text {
charStr := string(char)
// Calculate precise centering for the font glyph
vecSize := rl.MeasureTextEx(r.Font, charStr, float32(r.FontSize), 0)
charWidth := int32(vecSize.X)
pixelX := int32(gridX+colOffset)*r.CellWidth + (r.CellWidth-charWidth)/2
// 2. Execute the hardware-accelerated draw call
position := rl.NewVector2(float32(pixelX), float32(pixelY))
// Notice we cast our abstract uint32 color directly into a Raylib Color struct
rl.DrawTextEx(r.Font, charStr, position, float32(r.FontSize), 0, rl.GetColor(uint(colorHex)))
colOffset++
}
}
This implementation hides massive complexity from the game engine. The engine doesn’t need to load the .ttf font file, generate a texture atlas, calculate bilinear filtering, or measure bounding boxes. It just says “Draw an @ at 5,5 in Red”.
Input Mechanism
Output is only half the battle. If your game loop calls raylib.IsKeyPressed(), it is once again permanently bound to the framework.
The Display interface demands a slice of core.InputEvent. This completely abstracts away the physical hardware capturing the keystrokes.
func (r \*RaylibDisplay) PollInput() []core.InputEvent {
var events []core.InputEvent
if rl.IsKeyPressed(rl.KeyW) || rl.IsKeyPressedRepeat(rl.KeyW) {
events = append(events, core.InputEvent{Key: core.KeyW})
}
if rl.IsKeyPressed(rl.KeyS) || rl.IsKeyPressedRepeat(rl.KeyS) {
events = append(events, core.InputEvent{Key: core.KeyS})
}
// ...
return events
}
Because the engine only consumes core.InputEvent, I could swap the Raylib keyboard hook for an Xbox controller hook, a network socket (for multiplayer), or a pre-recorded slice of inputs (for deterministic unit testing), all without touching the core game loop.
Dependency Injection
When the program starts, the main function decides which reality the engine lives in. The Engine struct accepts the Display interface via Dependency Injection.
func main() {
// 1. Instantiate the concrete hardware layer
disp := display.NewRaylibDisplay(12, 24, 24, "assets/fonts/FiraCode-Bold.ttf")
// 2. Instantiate the game logic
generatedMap, _, _ := world.NewFacilityGenerator(1234).Generate(120, 30)
ecsWorld := ecs.NewWorld()
// 3. Inject the hardware layer into the agnostic engine
gameEngine := engine.NewEngine(disp, generatedMap, ecsWorld, world.TileVariantCold)
// 4. Start the simulation
gameEngine.Run()
}
If I want to run the game headless on a CI server to benchmark the pathfinding algorithm, I write a NullDisplay struct that implements the interface but does nothing.
The Interface Segregation Principle is rarely used correctly in modern microservice architectures, where interfaces are often just 1:1 mocks for database calls.
But in systems engineering and game development, interfaces are the blast doors that protect your complex, expensive business logic (procedural generation, AI, physics) from the volatile, ever-changing demands of I/O hardware.
This is Part 2 of the Building a Game Engine in Pure Go series. The full source code for Derelict Facility is available on GitHub.