# Part 6: Simulating a Living Facility: Room Derivation, Sunlight Spillover, and Positional SFX

> **Source:** [https://lorbic.com/modular-missions-floodfill-audio-raylib/](https://lorbic.com/modular-missions-floodfill-audio-raylib/)
> **Author:** [Vikash Patel](https://vikashpatel.net)
> **Published:** August 10, 2026
> **Reading Time:** 6 min
> 
> *This is the raw Markdown source of the article from the [Lorbic Technical Journal](https://lorbic.com/).*

---


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.

{{< youtube p4MoXKkQl4A >}}

## Engine Simulation Architecture

The diagram below shows how the raw tile map flows into spatial systems during initialization and runtime updates:

```mermaid
graph TD
    RawMap["Raw Tile Array (1D Slice)"] --> BFS["BFS Flood Fill (deriveRooms)"]
    BFS --> Rooms["Logical Room Bounding Boxes (Rect)"]
    
    GlassTiles["Skylight Glass Tiles"] --> LightProp["Recursive Light Decay (PropagateSunlight)"]
    LightProp --> DaylightFactors["Tile Daylight Factors (0.0 to 1.0)"]
    
    AudioEvent["Spatial Sound Event (x, y)"] --> AudioAtten["Positional Attenuation (PlayPositionalSound)"]
    PlayerPos["Player Coordinates (px, py)"] --> AudioAtten
    AudioAtten --> RaylibAudio["Raylib CGO Sound Engine"]
```

---

## 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
1. Iterate over map coordinates to locate unvisited floor tiles that are not doors.
2. Initialize a queue seeded with the starting coordinate and mark it as visited.
3. Traverse cardinal neighbors (Up, Down, Left, Right). Stop expanding when encountering walls or door tiles.
4. Track the minimum and maximum bounds (`minX`, `minY`, `maxX`, `maxY`) to construct a bounding box (`Rect`) representing the room.

{{< code lang="go" file="internal/world/json_loader.go" >}}
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})
				}
			}
		}
	}
}
{{< /code >}}

### 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.

{{< code lang="go" file="internal/world/map.go" >}}
// 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)
    }
}
{{< /code >}}

### 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`).

{{< code lang="json" file="assets/missions/sector_4_incident/mission.json" >}}
{
  "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" }
  ]
}
{{< /code >}}

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.

{{< details title="What is Audio Attenuation?" label="Glossary //" >}}
**Attenuation** just means volume drops off with distance. In code, it means scaling a sound's volume based on how far the noise is from the player. A terminal exploding next to you plays at 100% volume, while an alarm 20 tiles away in another corridor plays at 10% volume or gets muted entirely.
{{< /details >}}

`PlayPositionalSound` (`internal/audio/audio.go`) wraps Raylib's CGO audio bindings to calculate Euclidean distance and horizontal panning:

{{< code lang="go" file="internal/audio/audio.go" >}}
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)
}
{{< /code >}}

### 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.

