- Extracting logical room boundaries from flat 2D tile arrays using BFS flood fills.
- Modeling directional sunlight propagation through open doorways with light decay.
- Loading modular campaign manifests and map configurations via JSON.
- Computing distance-based volume falloff and stereo panning with Raylib CGO.
Generating a raw matrix of integer tiles gives you a map layout, but it doesn’t give you a living environment. When I loaded my first level into Derelict Facility, the engine had no concept of what a “Laboratory” or a “Reactor Room” was. It just saw a flat array of walls and floors.
If a player flipped a power terminal inside a room, I had no clean way to know which room lights or doors to toggle without scanning the whole map grid on every frame. If an alarm fired down the hall, it played at full volume regardless of where the player was standing.
To make the environment feel reactive, I built spatial abstractions on top of the raw map: extracting room boundaries via flood fill, spilling sunlight through open doorways, loading modular JSON missions, and attenuating positional audio.
Engine Simulation Architecture
The diagram below shows how the raw tile map flows into spatial systems during initialization and runtime updates:
Procedural Room Derivation via BFS Flood Fill
Map data loaded from disk only marks individual coordinates as TileTypeFloor or TileTypeWall. To group contiguous floor regions into logical rooms, we run a Breadth-First Search (BFS) flood fill (internal/world/json_loader.go).
I chose BFS over recursion (DFS) here to guarantee bounded memory consumption on large maps and avoid stack overflow risk during level initialization.
Extraction Pipeline
- Iterate over map coordinates to locate unvisited floor tiles that are not doors.
- Initialize a queue seeded with the starting coordinate and mark it as visited.
- Traverse cardinal neighbors (Up, Down, Left, Right). Stop expanding when encountering walls or door tiles.
- Track the minimum and maximum bounds (
minX,minY,maxX,maxY) to construct a bounding box (Rect) representing the room.
func (l *JSONMapLoader) deriveRooms(m *Map) {
visited := make([]bool, m.Width*m.Height)
isDoor := make([]bool, m.Width*m.Height)
for _, d := range m.Doors {
isDoor[d.Y*m.Width+d.X] = true
}
for y := 0; y < m.Height; y++ {
for x := 0; x < m.Width; x++ {
idx := y*m.Width + x
tile := m.GetTile(x, y)
if tile != nil && tile.Type == TileTypeFloor && !visited[idx] && !isDoor[idx] {
minX, minY := x, y
maxX, maxY := x, y
queue := []entity.Point{{X: x, Y: y}}
visited[idx] = true
for len(queue) > 0 {
p := queue[0]
queue = queue[1:]
if p.X < minX { minX = p.X }
if p.Y < minY { minY = p.Y }
if p.X > maxX { maxX = p.X }
if p.Y > maxY { maxY = p.Y }
neighbors := []entity.Point{
{X: p.X, Y: p.Y - 1}, {X: p.X, Y: p.Y + 1},
{X: p.X - 1, Y: p.Y}, {X: p.X + 1, Y: p.Y},
}
for _, n := range neighbors {
if n.X < 0 || n.X >= m.Width || n.Y < 0 || n.Y >= m.Height {
continue
}
nIdx := n.Y*m.Width + n.X
nTile := m.GetTile(n.X, n.Y)
if nTile != nil && nTile.Type == TileTypeFloor && !visited[nIdx] && !isDoor[nIdx] {
visited[nIdx] = true
queue = append(queue, n)
}
}
}
if (maxX-minX) >= 1 && (maxY-minY) >= 1 {
m.Rooms = append(m.Rooms, Rect{X1: minX, Y1: minY, X2: maxX, Y2: maxY})
}
}
}
}
}
Architectural Trade-off
Storing rooms as bounding boxes (Rect) enables fast O(1) point-in-rectangle collision checks. However, this assumption breaks for concave or L-shaped rooms, where a bounding box might overlap wall coordinates. For non-rectangular layouts, storing an explicit bitset of tile coordinates per room is necessary.
Dynamic Sunlight Propagation
Skylight glass tiles (IsSunlit) act as primary light sources, receiving exterior ambient light calculated from the engine’s time clock (internal/world/clock.go). To bleed light into dark corridors, we propagate sunlight into adjacent non-wall tiles using multiplicative decay.
// PropagateSunlight recursively bleeds daylight from sunlit tiles into adjacent spaces.
func (m *Map) PropagateSunlight(x, y int, currentLight float32) {
if currentLight < 0.1 {
return
}
tile := m.GetTile(x, y)
if tile == nil || tile.Type == TileTypeWall {
return
}
// Only propagate if incoming light exceeds existing tile light value
if currentLight > tile.DaylightFactor {
tile.DaylightFactor = currentLight
// 25% attenuation per grid step
decay := currentLight * 0.75
m.PropagateSunlight(x+1, y, decay)
m.PropagateSunlight(x-1, y, decay)
m.PropagateSunlight(x, y+1, decay)
m.PropagateSunlight(x, y-1, decay)
}
}
Termination Guard & Performance
The check if currentLight > tile.DaylightFactor serves as the recursion boundary guard. Because decay reduces intensity by 25% each step, currentLight decreases monotonically until it drops below 0.1 or hits a tile already lit by a brighter path.
If a door is closed (TileTypeWall), recursion halts immediately. Opening a door re-runs light propagation, allowing sunlight to flood dark hallways without recalculating global scene lighting.
Modular Campaigns via JSON Manifests
Hardcoding map loading scripts makes content creation brittle. I decoupled narrative data and level ordering into structured JSON mission manifests (internal/mission/mission.go).
{
"id": "sector_4_incident",
"title": "Sector 4 Incident",
"author": "Vikash Patel",
"synopsis": "Investigate structural power failure in Sector 4 Research Labs.",
"start_level": "level_01_surface",
"levels": [
{ "id": "level_01_surface", "name": "Surface Entry", "file": "maps/level_01_surface.json" },
{ "id": "level_02_labs", "name": "Research Laboratories", "file": "maps/level_02_labs.json" }
]
}
The JSONMapLoader reads map definitions, instantiating tiles, door entities, terminals (T), room generators (g), and stairs (< / >) directly into the ECS registry.
2D Positional Audio Attenuation
Visual feedback alone is insufficient for spatial awareness. Sound effects need volume falloff and stereo panning based on their relative distance to the player.
Glossary //
What is Audio Attenuation?
PlayPositionalSound (internal/audio/audio.go) wraps Raylib’s CGO audio bindings to calculate Euclidean distance and horizontal panning:
func PlayPositionalSound(sound rl.Sound, playerX, playerY, sourceX, sourceY int, maxRange float32) {
dx := float32(sourceX - playerX)
dy := float32(sourceY - playerY)
distance := float32(math.Sqrt(float64(dx*dx + dy*dy)))
if distance > maxRange {
return
}
// Linear volume attenuation
volume := 1.0 - (distance / maxRange)
rl.SetSoundVolume(sound, volume)
// Stereo panning based on relative horizontal offset (-1.0 left, 1.0 right)
pan := 0.5 + (dx / (maxRange * 2.0))
if pan < 0.0 { pan = 0.0 }
if pan > 1.0 { pan = 1.0 }
rl.SetSoundPan(sound, pan)
rl.PlaySound(sound)
}
Audio Design Choices
For a 2D tile-based engine, linear volume falloff (1.0 - distance/maxRange) provides predictable player feedback compared to inverse-square log attenuation, which can drop off too abruptly in constrained indoor hallways.
Summary
Instead of relying on heavy third-party scene graphs, spatial logic in Derelict Facility relies on lightweight grid operations:
- BFS Flood Fill: Clusters raw floor coordinates into room bounding boxes for fast spatial queries.
- Recursive Attenuation: Bleeds sunlight dynamically through open doorways until light intensity drops below
0.1. - JSON Level Pipelines: Decouples campaign structure from core engine code.
- Euclidean Audio Panning: Maps 2D relative tile distances directly to Raylib stereo volume channels.