PVG
48. Entity Spawners — Program Video Games

48. Entity Spawners

In this lesson, we'll implement entity spawners that can be placed and removed in the level editor. This involves updating our file format to support arbitrary entity types and their spawn positions.

File Format Changes

Let's start by understanding the new file format structure. I've added detailed documentation in encoding.odin:

/*
+---------------------------------------+
| World_Data_Header                     |
|---------------------------------------|
| magic         (0x57475650)    4 bytes |
| version_major                 4 bytes |
| version_minor                 4 bytes |
| version_patch                 4 bytes |
| level_count                   4 bytes |
| tileset_count                 4 bytes |
+---------------------------------------+
| Entity Table                          |
|---------------------------------------|
| magic         (0xEFBEEFBE)    4 bytes |
| entity_count                  4 bytes |
+---------------------------------------+
| Name Length 1                 4 bytes |
| Name Length 2                 4 bytes |
| ...                                   |
| Name Length N                 4 bytes |
+---------------------------------------+
| Entity Name 1            variable len |
| Entity Name 2            variable len |
| ...                                   |
| Entity Name N            variable len |
+---------------------------------------+
| Level 1                               |
|---------------------------------------|
| magic         (0xBEBAFECA)    4 bytes |
| id                            4 bytes |
| x                             4 bytes |
| y                             4 bytes |
| width                         4 bytes |
| height                        4 bytes |
+---------------------------------------+
| Tile Data                             |
| (width * height bytes)       1 byte   |
| per tile                              |
+---------------------------------------+
| Spawners      (Optional)              |
|---------------------------------------|
| magic         (0xEFBEDEC0)    4 bytes |
| spawner_count                 4 bytes |
+---------------------------------------+
| Entity Index 1                4 bytes |
| X 1                           4 bytes |
| Y 1                           4 bytes |
+---------------------------------------+
| Entity Index 2                4 bytes |
| X 2                           4 bytes |
| Y 2                           4 bytes |
+---------------------------------------+
| ...                                   |
+---------------------------------------+
| Level 2                               |
| ...                                   |
+---------------------------------------+
*/

The key additions are:

  1. Entity Table: Lists all entity types available in the game

  2. Spawners Section: Optional per-level section containing entity spawn positions

New Constants and Types

In encoding.odin, add the new magic numbers and tile value enum:

ENTITY_TABLE_MAGIC :: 0xEFBEEFBE
SPAWNERS_MAGIC :: 0xEFBEDEC0

Tile_Value :: enum u8 {
    None,
    Solid,
    Spike_N,
    Spike_E,
    Spike_S,
    Spike_W,
}

Also add the necessary imports:

import "core:slice"
import "core:strings"

Update Level Structure

In main.odin, replace enemy_spawns with a more generic spawners field:

Level :: struct {
    // ... other fields ...
    spawners: [dynamic]Spawner,
    // ... other fields ...
}

Spawner :: struct {
    type: string,
    pos:  Vec2,
}

Editor Commands

In editor.odin, add new command types for entity operations:

Cmd :: union {
    // ... existing commands ...
    Cmd_Entity_Insert,
    Cmd_Entity_Remove,
}

Cmd_Entity_Insert :: struct {
    coords: Vec2i,
    type:   string,
}

Cmd_Entity_Remove :: struct {
    coords: Vec2i,
    type:   string,
}

Implement Command Execution

In editor_command_execute, add cases for the new commands:

case Cmd_Entity_Insert:
    pos := pos_from_coords(v.coords)
    found := false
    for spawner in gs.level.spawners {
        if spawner.pos == pos {
            found = true
            break
        }
    }

    if !found {
        append(&gs.level.spawners, Spawner {pos = pos, type = v.type})
    }
case Cmd_Entity_Remove:
    pos := pos_from_coords(v.coords)
    for spawner, i in gs.level.spawners {
        if spawner.pos == pos {
            unordered_remove(&gs.level.spawners, i)
            break
        }
    }

Update Spike Placement

Spikes are now placed individually rather than as joined areas. In editor_command_construct, update the spike placement logic:

area_coords := coords_from_area(es.area_begin, es.area_end)
spikes := make([]Spike, len(area_coords))

for coords, i in area_coords {
    pos := pos_from_coords(coords)

    spike := Spike {
        collider = {pos.x, pos.y, TILE_SIZE, TILE_SIZE},
        facing   = es.spike_orientation,
    }

    switch spike.facing {
    case .Up:
        spike.collider.y += SPIKE_DIFF
        spike.collider.height = SPIKE_DEPTH
    case .Right:
        spike.collider.width = SPIKE_DEPTH
    case .Down:
        spike.collider.height = SPIKE_DEPTH
    case .Left:
        spike.collider.width = SPIKE_DEPTH
        spike.collider.x += SPIKE_DIFF
    }

    spikes[i] = spike
}

Entity Insert/Remove Commands

Add construction for entity commands in editor_command_construct:

case Cmd_Entity_Insert:
    mouse_pos := rl.GetMousePosition()
    world_pos := rl.GetScreenToWorld2D(mouse_pos, gs.camera)
    coords := coords_from_pos(world_pos)

    if is_tile_at(coords, gs.level) || is_spawner_at(coords, gs.level) {
        return {}, {}
    }

    forward := Cmd_Entity_Insert {
        coords = coords,
        type   = es.entity_type_key,
    }

    inverse := Cmd_Entity_Remove {
        coords = coords,
        type   = es.entity_type_key,
    }

    return {forward, inverse}, true
case Cmd_Entity_Remove:
    mouse_pos := rl.GetMousePosition()
    world_pos := rl.GetScreenToWorld2D(mouse_pos, gs.camera)
    coords := coords_from_pos(world_pos)

    if !is_spawner_at(coords, gs.level) {
        return {}, {}
    }

    forward := Cmd_Entity_Remove {
        coords = coords,
        type   = es.entity_type_key,
    }

    inverse := Cmd_Entity_Insert {
        coords = coords,
        type   = es.entity_type_key,
    }

    return {forward, inverse}, true

Helper Function

Add the is_spawner_at helper function:

is_spawner_at :: proc(coords: Vec2i, l: ^Level = gs.level) -> bool {
    for spawner in l.spawners {
        spawner_coords := coords_from_pos(spawner.pos)
        if spawner_coords == coords {
            return true
        }
    }
    return false
}

Update Editor Input

In editor_update, update the entity tool handling to use the command system:

if rl.IsMouseButtonPressed(.LEFT) {
    editor_command_dispatch(Cmd_Entity_Insert)
}

if rl.IsMouseButtonPressed(.RIGHT) {
    editor_command_dispatch(Cmd_Entity_Remove)
}

Save File Format

Update world_data_save to write the entity table and spawners:

// Create Entity Table
bytes.buffer_write(&b, mem.any_to_bytes(u32(ENTITY_TABLE_MAGIC)))

entity_count := len(gs.entity_definitions)
bytes.buffer_write(&b, mem.any_to_bytes(u32(entity_count)))

// Name lengths
for k in gs.entity_definitions {
    bytes.buffer_write(&b, mem.any_to_bytes(u32(len(k))))
}

entity_names := make([dynamic]string, context.temp_allocator)

for k in gs.entity_definitions {
    bytes.buffer_write_string(&b, k)
    append(&entity_names, k)
}

Update tile saving to use the new Tile_Value enum:

tiles := make([]Tile_Value, level_header.width * level_header.height, context.temp_allocator)

for tile in level.tiles {
    coords := coords_from_pos(tile.pos - level.pos)
    tiles[u32(coords.y) * level_header.width + u32(coords.x)] = .Solid
}

for spike in level.spikes {
    coords := coords_from_pos({spike.collider.x, spike.collider.y})
    spike_value := Tile_Value.Spike_N
    if spike.facing == .Right do spike_value = .Spike_E
    if spike.facing == .Down do spike_value = .Spike_S
    if spike.facing == .Left do spike_value = .Spike_W

    tiles[u32(coords.y) * level_header.width + u32(coords.x)] = spike_value
}

bytes.buffer_write(&b, transmute([]u8)tiles)

Save spawners after tile data:

if len(level.spawners) > 0 {
    bytes.buffer_write(&b, mem.any_to_bytes(u32(SPAWNERS_MAGIC)))
    bytes.buffer_write(&b, mem.any_to_bytes(u32(len(level.spawners))))

    for spawner in level.spawners {
        coords := coords_from_pos(spawner.pos)
        index, ok := slice.linear_search(entity_names[:], spawner.type)
        if ok {
            bytes.buffer_write(&b, mem.any_to_bytes(u32(index)))
            bytes.buffer_write(&b, mem.any_to_bytes(i32(coords.x)))
            bytes.buffer_write(&b, mem.any_to_bytes(i32(coords.y)))
        }
    }
}

Load File Format

Update world_data_load to read the entity table:

// Construct Entity Table
entity_table_magic: u32
bytes.reader_read(&r, mem.any_to_bytes(entity_table_magic))

entity_count: u32
bytes.reader_read(&r, mem.any_to_bytes(entity_count))

name_lengths := make([dynamic]u32, context.temp_allocator)
names := make([dynamic]string, context.temp_allocator)

for _ in 0..< entity_count {
    name_length: u32
    bytes.reader_read(&r, mem.any_to_bytes(u32(name_length)))
    append(&name_lengths, name_length)
}

for i in 0..< entity_count {
    name := make([]u8, name_lengths[i], context.temp_allocator)
    bytes.reader_read(&r, name)
    append(&names, string(name))
}

Update tile loading to handle the new format:

for y in 0 ..< level_header.height {
    for x in 0 ..< level_header.width {
        tile_byte: u8
        bytes.reader_read(&r, mem.any_to_bytes(tile_byte))
        tile_value := transmute(Tile_Value)tile_byte

        pos := level.pos + pos_from_coords({i32(x), i32(y)})

        switch tile_value {
        case .None:
        case .Solid:
            append(&level.tiles, Tile {pos = pos})
        case .Spike_N:
            append(&level.spikes, Spike {collider = {pos.x, pos.y + SPIKE_DIFF, SPIKE_BREADTH, SPIKE_DEPTH}, facing = .Up})
        case .Spike_E:
            append(&level.spikes, Spike {collider = {pos.x, pos.y, SPIKE_DEPTH, SPIKE_BREADTH}, facing = .Right})
        case .Spike_S:
            append(&level.spikes, Spike {collider = {pos.x, pos.y, SPIKE_BREADTH, SPIKE_DEPTH}, facing = .Down})
        case .Spike_W:
            append(&level.spikes, Spike {collider = {pos.x + SPIKE_DIFF, pos.y, SPIKE_DEPTH, SPIKE_BREADTH}, facing = .Left})
        }
    }
}

Load spawners if present:

spawners_magic: u32
bytes.reader_read(&r, mem.any_to_bytes(spawners_magic))

if spawners_magic == SPAWNERS_MAGIC {
    spawner_count: u32
    bytes.reader_read(&r, mem.any_to_bytes(spawner_count))

    for _ in 0..< spawner_count {
        index: u32
        x, y: i32
        bytes.reader_read(&r, mem.any_to_bytes(index))
        bytes.reader_read(&r, mem.any_to_bytes(x))
        bytes.reader_read(&r, mem.any_to_bytes(y))

        append(&level.spawners, Spawner {
            pos = Vec2{f32(x), f32(y)} * TILE_SIZE,
            type = strings.clone(names[index])
        })
    }
} else {
    bytes.reader_unread_byte(&r)
    bytes.reader_unread_byte(&r)
    bytes.reader_unread_byte(&r)
    bytes.reader_unread_byte(&r)
}

Update Level Loading

In level_load, spawn entities from the spawners:

for spawner in level.spawners {
    entity_spawn(spawner.type, spawner.pos)
}

Visual Feedback

In game_update, draw spawners as orange squares during gameplay (for debugging):

for spawner in gs.level.spawners {
    rl.DrawRectangleLinesEx({spawner.pos.x, spawner.pos.y, 16, 16}, 1, rl.ORANGE)
}

Summary

We've successfully implemented a flexible entity spawner system that:

  • Stores entity type names in a global entity table
  • Allows placing and removing spawners in the editor
  • Saves spawner data as an optional section in level files
  • Automatically spawns entities when levels are loaded
  • Provides visual feedback for spawner locations