Open to Work: Senior Backend & Distributed Systems Engineer (Go / Python) View LinkedIn →

Part 5: From Terminal Cells to Sprite Maps: Font Fallbacks and Auto-Tiling

Building a 2D Raylib rendering pipeline capable of 4-bit cardinal autotiling and multi-font fallback chains for unicode glyphs and emojis.

WORDS: 744 | CODE BLOCKS: 6 | EXT. LINKS: 0
// TL;DR
  • Mapping wall connectivity using 4-bit cardinal neighbor bitmask calculation rules.
  • Resolving vector glyphs across multi-font fallback chains in Raylib.
  • Decoupling tile coordinate bitmasks from sprite sheet source rectangle rendering.

Building a terminal-based engine in pure ASCII looks cool for about five minutes. Then you try rendering a complex facility map with corners, T-junctions, and status icons, and raw character cells start feeling incredibly limiting.

Two specific problems hit me immediately when I tried switching to Raylib for graphics. First, drawing wall tiles manually by hand in level files meant placing 16 different corner variations by hand. Second, when I tried rendering a 🚨 warning emoji alongside FiraCode monospace text, Raylib just rendered a missing glyph box (? or ).

To solve this, Derelict Facility uses a 4-bit cardinal autotiling algorithm alongside a cascading font fallback resolver in the Raylib display pipeline (internal/display/raylib.go).


4-Bit Cardinal Autotiling Mathematics

Drawing walls as uniform square blocks (#) breaks visual continuity in corridors. We want corners, T-junctions, and straight hallways to select their matching sprite tile automatically.

Instead of manual texture assignment, we evaluate each wall tile’s 4 cardinal neighbors (North, East, South, West) and compile a 4-bit integer bitmask.

graph TD subgraph NeighborBitmask ["4-Bit Cardinal Bitmask Rules"] N["North: Bit 0 (Value 1)"] E["East: Bit 1 (Value 2)"] S["South: Bit 2 (Value 4)"] W["West: Bit 3 (Value 8)"] end NeighborBitmask --> Sum["Bitwise OR Sum (0 to 15)"] Sum --> SpriteMap["Tileset Source Rect (Bitmask * 16px, 0)"]

Cardinal Neighbor Values

Each direction is mapped to a power-of-two bit index:

  • North (N): 1 << 0 = 1
  • East (E): 1 << 1 = 2
  • South (S): 1 << 2 = 4
  • West (W): 1 << 3 = 8

During level loading (internal/world/json_loader.go), we inspect adjacent tiles and construct the bitmask:

go internal/world/json_loader.go

func (l *JSONMapLoader) calculateWallBitmasks(m *Map) {
	for y := 0; y < m.Height; y++ {
		for x := 0; x < m.Width; x++ {
			tile := m.GetTile(x, y)
			if tile == nil || tile.Type != TileTypeWall {
				continue
			}

			var mask uint8 = 0
			if tN := m.GetTile(x, y-1); tN != nil && tN.Type == TileTypeWall { mask |= 1 }
			if tE := m.GetTile(x+1, y); tE != nil && tE.Type == TileTypeWall { mask |= 2 }
			if tS := m.GetTile(x, y+1); tS != nil && tS.Type == TileTypeWall { mask |= 4 }
			if tW := m.GetTile(x-1, y); tW != nil && tW.Type == TileTypeWall { mask |= 8 }

			tile.Bitmask = mask
		}
	}
}

This produces a single byte value between 0 (an isolated wall column) and 15 (a 4-way intersection).

Tileset Coordinate Mapping

The computed Bitmask maps directly to sprite sheet source X offsets:

1Bitmask Value -> Visual Connection -> Sprite Sheet Offset X
20             -> Isolated Pillar    -> 0px
31             -> North Cap          -> 16px
43 (1+2)       -> N-E Corner         -> 48px
515 (1+2+4+8)  -> 4-Way Cross        -> 240px

Autotiling Trade-offs (4-Bit vs. 8-Bit)

4-bit cardinal autotiling requires only 16 sprite variations, keeping tileset textures small. However, cardinal autotiling does not evaluate diagonal neighbors (NW, NE, SE, SW). If your game requires outer vs. inner corner transitions on thick 2D terrain, an 8-bit (47-tile Blob autotiling) lookup table is required instead.


Cascading Font Fallback Chain

Standard font loading functions in graphics libraries bind a single TTF/OTF font file. When rendering terminal UI text mixed with unicode indicators or status emojis, missing glyphs trigger placeholder box rendering.

To fix this, we implement a fallback resolver (internal/display/raylib.go) that queries a prioritized chain of loaded font instances:

graph LR Rune["Target Unicode Rune"] --> CheckPrimary{"IsGlyphAvailable(PrimaryFont)?"} CheckPrimary -- Yes --> RenderPrimary["Render with FiraCode"] CheckPrimary -- No --> CheckEmoji{"IsGlyphAvailable(EmojiFont)?"} CheckEmoji -- Yes --> RenderEmoji["Render with Noto Emoji"] CheckEmoji -- No --> RenderSymbol["Render with Symbola"]
go internal/display/raylib.go

func (r *RaylibDisplay) getFontForGlyph(char rune) rl.Font {
    if rl.IsGlyphAvailable(r.PrimaryFont, char) {
        return r.PrimaryFont
    }
    
    if rl.IsGlyphAvailable(r.EmojiFont, char) {
        return r.EmojiFont
    }
    
    return r.SymbolaFont
}

Render Loop Integration

During draw passes, the display driver uses the calculated bitmask for sprite sheet clipping while routing text glyphs through the font fallback chain:

go internal/display/raylib.go

func (r *RaylibDisplay) DrawMapTile(gridX, gridY int, tile world.Tile) {
    pixelX := int32(gridX) * r.CellWidth
    pixelY := int32(gridY) * r.CellHeight
    
    if tile.Type == world.TileTypeWall {
        srcRect := rl.NewRectangle(float32(tile.Bitmask)*16, 0, 16, 16)
        destRect := rl.NewRectangle(float32(pixelX), float32(pixelY), float32(r.CellWidth), float32(r.CellHeight))
        rl.DrawTexturePro(r.Tileset, srcRect, destRect, rl.NewVector2(0,0), 0, rl.White)
    } else {
        rl.DrawRectangle(pixelX, pixelY, r.CellWidth, r.CellHeight, rl.GetColor(0x222222FF))
    }
}

Summary

Combining 4-bit cardinal autotiling bitmasks with cascading font fallback chains resolves two core 2D rendering bottlenecks:

  • Automated Autotiling: Eliminates manual wall placement errors by compiling neighbor connections into a single 0 to 15 byte index.
  • Robust Text & Emoji Rendering: Prevents missing glyph artifacts when mixing vector fonts with unicode emojis in game UI layers.