spatial

package module
v0.15.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 14, 2026 License: GPL-3.0 Imports: 14 Imported by: 7

README

Spatial Module

The spatial module provides 2D spatial positioning and movement capabilities for the RPG Toolkit. It supports multiple grid systems, entity placement, movement tracking, and event-driven spatial queries.

Table of Contents

Overview

The spatial module is designed to handle 2D spatial positioning for tabletop RPGs and provides:

  • Multiple Grid Systems: Square, offset-coordinate hex, axial-coordinate hex, and continuous gridless systems
  • Entity Management: Place, move, and track entities in spatial environments
  • Multi-Room Orchestration: Connect and manage multiple rooms with typed connections
  • Event Integration: Automatic event publishing for spatial changes
  • Query System: Efficient spatial queries for game mechanics
  • Line of Sight: Calculate visibility and obstacles
  • Boundary Crossings: Optional undirected barriers between adjacent cells
  • Distance Calculation: Grid-appropriate distance calculations
  • Layout Patterns: Common spatial arrangements (towers, dungeons, towns)

Key Concepts

Position

A Position represents a 2D coordinate in space:

type Position struct {
    X float64 `json:"x"`
    Y float64 `json:"y"`
}
Grid

The Grid interface defines how spatial calculations work:

  • Square Grid: Uses Chebyshev distance
  • HexGrid: Uses bounded, non-negative offset column/row coordinates with pointy-top or flat-top orientation
  • AxialHexGrid: Uses origin-centered axial Q/R coordinates
  • Gridless: Uses Euclidean distance on continuous positions
Room

A Room is a spatial container that implements core.Entity and manages:

  • Entity placement and movement
  • Grid-based spatial calculations
  • Event publishing for changes
  • Line of sight and range queries
Placeable

Entities that can be placed in rooms should implement the Placeable interface:

type Placeable interface {
    core.Entity
    GetSize() int
    BlocksMovement() bool
    BlocksLineOfSight() bool
}

Boundary Crossings

Room remains occupancy-focused. BasicRoom additionally implements the optional BoundaryAwareRoom capability for a barrier between two adjacent, in-grid positions. A boundary is normalized as an undirected pair when it is registered, so both directions share one record. It never occupies either endpoint. It applies to discrete square and hex grids; gridless rooms have no cell-pair crossing model and reject boundary registration. With no registered boundaries, BasicRoom retains its legacy movement, movement-query, and entity line-of-sight behavior without constructing or scanning a boundary-specific ray.

boundaryRoom := room // *spatial.BasicRoom
err := boundaryRoom.RegisterBoundary(spatial.Boundary{
    From:              spatial.Position{X: 4, Y: 5},
    To:                spatial.Position{X: 5, Y: 5},
    BlocksMovement:    true,
    BlocksLineOfSight: true,
})

// Movement and LoS both reject the crossing. Placement at either cell stays valid.
err = boundaryRoom.MoveEntity("hero-1", spatial.Position{X: 5, Y: 5})
blocked := boundaryRoom.IsLineOfSightBlocked(
    spatial.Position{X: 4, Y: 5},
    spatial.Position{X: 5, Y: 5},
)

// Re-register to change flags (for example, an opened door), or remove it.
err = boundaryRoom.RegisterBoundary(spatial.Boundary{
    From: spatial.Position{X: 4, Y: 5},
    To:   spatial.Position{X: 5, Y: 5},
})
err = boundaryRoom.RemoveBoundary(
    spatial.Position{X: 4, Y: 5},
    spatial.Position{X: 5, Y: 5},
)

Boundaries are transient spatial infrastructure, not persistence or content ownership. A higher-level dungeon/encounter owns authored records and registers its current crossings when rebuilding a room. Boundary endpoints must be finite integer cell coordinates; fractional and non-finite values are rejected before a pair is normalized.

Multi-cell MoveEntity calls and movement queries retain direct-move semantics: they follow the grid-provided GetLineOfSight ray, reject any blocked consecutive crossing, and do not find a detour. Boundary LoS checks use one lexicographically ordered endpoint ray so a boundary has the same result in either direction even when square Bresenham chooses different directional rays. Entity and boundary blockers use that same canonical ray for each evaluated sight lane.

For hex A*, SimplePathFinder.FindPathWithTraversal accepts a TraversalPredicate so callers can reject a crossing without treating either endpoint as a blocked cell. It also requires an explicit TraversalSearchLimit; MaxSteps bounds both returned path length and search cost, making a sealed-but-unblocked goal terminate on the unbounded hex plane. Choose a limit above the direct distance by the detour budget appropriate for the map. An empty result means either no route exists or no route fits that budget.

path := finder.FindPathWithTraversal(
    start,
    goal,
    blockedCells,
    canTraverse,
    spatial.TraversalSearchLimit{MaxSteps: 64},
)

Quick Start

1. Basic Setup
package main

import (
    "github.com/KirkDiggler/rpg-toolkit/core"
    "github.com/KirkDiggler/rpg-toolkit/events"
    "github.com/KirkDiggler/rpg-toolkit/tools/spatial"
)

func main() {
    // Create event bus
    eventBus := events.NewBus()
    
    // Create a 20x20 square grid
    grid := spatial.NewSquareGrid(spatial.SquareGridConfig{
        Width:  20,
        Height: 20,
    })
    
    // Create a room
    room := spatial.NewBasicRoom(spatial.BasicRoomConfig{
        ID:       "dungeon-room-1",
        Type:     "dungeon",
        Grid:     grid,
        EventBus: eventBus,
    })
    
    // Setup query system
    queryHandler := spatial.NewSpatialQueryHandler()
    queryHandler.RegisterRoom(room)
    
    // Create query utilities
    queryUtils := spatial.NewQueryUtils(queryHandler)
}
2. Entity Placement
// Create an entity (must implement core.Entity)
type Character struct {
    id   string
    name string
}

func (c *Character) GetID() string   { return c.id }
func (c *Character) GetType() string { return "character" }

// Create and place entity
hero := &Character{id: "hero-1", name: "Aragorn"}
position := spatial.Position{X: 10, Y: 10}

err := room.PlaceEntity(hero, position)
if err != nil {
    log.Fatal(err)
}
3. Movement and Queries
// Move entity
newPosition := spatial.Position{X: 12, Y: 10}
err := room.MoveEntity("hero-1", newPosition)

// Query entities in range
entities := room.GetEntitiesInRange(position, 5.0)

// Check line of sight
losPositions := room.GetLineOfSight(position, newPosition)
blocked := room.IsLineOfSightBlocked(position, newPosition)

Grid Systems

Square Grid

SquareGrid uses Chebyshev distance, so diagonal and orthogonal neighbors are both one unit apart.

grid := spatial.NewSquareGrid(spatial.SquareGridConfig{
    Width:  20,
    Height: 20,
})
Offset-Coordinate Hex Grid

HexGrid interprets Position.X/Y as bounded, non-negative offset column/row coordinates. It converts those values to cube coordinates internally and honors the configured pointy-top or flat-top orientation.

grid := spatial.NewHexGrid(spatial.HexGridConfig{
    Width:       15,
    Height:      15,
    Orientation: spatial.HexOrientationPointyTop,
})
Axial-Coordinate Hex Grid

AxialHexGrid interprets Position.X/Y directly as axial Q/R coordinates. Its bounds are centered on the origin, so negative coordinates can be valid. It has no orientation setting because axial coordinates already describe the hex axes.

grid := spatial.NewAxialHexGrid(spatial.AxialHexGridConfig{
    SpanWidth:  30,
    SpanHeight: 30,
})

These two hex implementations are intentionally distinct. A Position{X: 5, Y: 5} means offset column 5/row 5 to HexGrid, but axial Q=5/R=5 to AxialHexGrid; callers must select the implementation matching their stored coordinate contract.

Gridless

GridlessRoom uses Euclidean distance and allows fractional positioning.

grid := spatial.NewGridlessRoom(spatial.GridlessConfig{
    Width:  100.0,
    Height: 100.0,
})

Room Management

Creating Rooms
room := spatial.NewBasicRoom(spatial.BasicRoomConfig{
    ID:       "unique-room-id",
    Type:     "dungeon",  // or "outdoors", "tavern", etc.
    Grid:     grid,
    EventBus: eventBus,
})
Room Operations
// Place entity
err := room.PlaceEntity(entity, position)

// Move entity
err := room.MoveEntity(entityID, newPosition)

// Remove entity
err := room.RemoveEntity(entityID)

// Check if position is occupied
occupied := room.IsPositionOccupied(position)

// Get all entities at position
entities := room.GetEntitiesAt(position)

// Get entity position
pos, exists := room.GetEntityPosition(entityID)

Multi-Room Orchestration

The spatial module includes a powerful orchestration system for managing multiple connected rooms, enabling complex multi-room environments like dungeons, towns, towers, and more.

Key Concepts
RoomOrchestrator

A RoomOrchestrator manages multiple rooms and their connections:

  • Room Management: Add, remove, and track multiple rooms
  • Connection System: Define how rooms link together
  • Entity Tracking: Track entities across all managed rooms
  • Layout Patterns: Organize rooms using common spatial arrangements
  • Event Integration: Publish events for all orchestration changes
Connection Types

The orchestrator supports different connection types for various scenarios:

// Connection types
spatial.ConnectionTypeDoor     // Standard doorway
spatial.ConnectionTypeStairs   // Vertical connections (floors)
spatial.ConnectionTypePassage  // Open corridors/hallways
spatial.ConnectionTypePortal   // Magical transport
spatial.ConnectionTypeBridge   // Spanning gaps/obstacles
spatial.ConnectionTypeTunnel   // Underground passages
Layout Patterns

Common spatial arrangements for multiple rooms:

// Layout types
spatial.LayoutTypeTower      // Vertical stacking (floors)
spatial.LayoutTypeBranching  // Hub and spoke pattern
spatial.LayoutTypeGrid       // 2D grid arrangement
spatial.LayoutTypeOrganic    // Irregular connections
Standalone rooms and managed rooms

A BasicRoom remains a valid standalone spatial container: call its PlaceEntity, MoveEntity, and RemoveEntity methods directly. Once rooms participate in a multi-room field, entity membership mutation uses the orchestrator's additive ManagedRoomMutator seam instead:

placed, err := orchestrator.PlaceEntity(&spatial.PlaceEntityInput{
    RoomID: "entrance-hall",
    Entity: hero,
    Position: spatial.Position{X: 10, Y: 10},
})

moved, err := orchestrator.MoveEntity(&spatial.MoveEntityInput{
    RoomID: "entrance-hall",
    EntityID: core.EntityID(hero.GetID()),
    To: spatial.Position{X: 12, Y: 10},
})

These verbs synchronously update both the room and the orchestrator's entity-to-room index and return typed spatial deltas as values. The event bus is an optional observer tail only: connecting no bus, connecting after rooms are added, or using different buses for rooms and the orchestrator does not change membership correctness or outputs. Spatial events retain their existing topics and payloads for observers, but the orchestrator never subscribes to room notifications to learn its own results.

Go room interfaces are aliases, not ownership tokens. After AddRoom, retained room references can still bypass the managed seam, and the same room can be passed to more than one orchestrator. Both uses are unsupported because they can stale an index; #909 deliberately adds no callback or ownership-token machinery. Hosts serialize managed mutations. Concurrent read queries remain safe, but re-entrant managed mutation from a synchronous event observer is not a supported contract.

A cross-room transition has two explicit steps because a connection does not choose a physical destination position:

transitioned, err := orchestrator.TransitionEntity(&spatial.TransitionEntityInput{
    EntityID: core.EntityID(hero.GetID()),
    FromRoom: "entrance-hall",
    ToRoom: "treasure-room",
    ConnectionID: "main-door",
})
// transitioned.Entity is physically removed and unindexed;
// transitioned.Transition.PlacementRequired is true.

placed, err := orchestrator.PlaceEntity(&spatial.PlaceEntityInput{
    RoomID: "treasure-room",
    Entity: transitioned.Entity,
    Position: spatial.Position{X: 1, Y: 7},
})

During the interval between those calls, GetEntityRoom returns false and CanMoveEntityBetweenRooms returns false. The legacy MoveEntityBetweenRooms(string, string, string, string) error signature is retained for source compatibility, but now has the same honest departure-only behavior and discards the explicit output. New compositions should use TransitionEntity.

Basic Orchestrator Usage
1. Creating an Orchestrator
// Create orchestrator
orchestrator := spatial.NewBasicRoomOrchestrator(spatial.BasicRoomOrchestratorConfig{
    ID:       "dungeon-orchestrator",
    Type:     "orchestrator",
    Layout:   spatial.LayoutTypeOrganic,
})
2. Adding Rooms
// Create rooms
room1 := spatial.NewBasicRoom(spatial.BasicRoomConfig{
    ID:       "entrance-hall",
    Type:     "chamber",
    Grid:     spatial.NewSquareGrid(spatial.SquareGridConfig{Width: 20, Height: 20}),
})

room2 := spatial.NewBasicRoom(spatial.BasicRoomConfig{
    ID:       "treasure-room",
    Type:     "chamber", 
    Grid:     spatial.NewSquareGrid(spatial.SquareGridConfig{Width: 15, Height: 15}),
})

// Add to orchestrator
err := orchestrator.AddRoom(room1)
err = orchestrator.AddRoom(room2)
3. Creating Connections
// Create a door connection
door := spatial.CreateDoorConnection(
    "main-door",
    "entrance-hall", // From room
    "treasure-room", // To room
    1.0,             // Traversal cost
)

// Add connection to orchestrator
err := orchestrator.AddConnection(door)
4. Transitioning Entities Between Rooms

Use the managed placement and transition verbs shown above. TransitionEntity returns the removed core.Entity, the departure delta, and the logical transition; the composition then chooses the destination position and calls managed PlaceEntity. No subscriber is required to recover either result.

Connection Helper Functions

The module provides helper functions for creating different connection types:

Door Connections
// Bidirectional door (most common)
door := spatial.CreateDoorConnection(
    "door-1", "room-a", "room-b", 1.0,
)
// Cost: 1.0, Reversible: true, Requirements: none
Stair Connections
// Stairs between floors
stairs := spatial.CreateStairsConnection(
    "stairs-up", "floor-1", "floor-2", 2.0,
    true, // goingUp - adds "can_climb" requirement
)
// Cost: 2.0, Reversible: true, Requirements: ["can_climb"] if going up
Portal Connections
// Magical portal
portal := spatial.CreatePortalConnection(
    "magic-portal", "material-plane", "feywild", 0.5,
    true, // bidirectional
)
// Cost: 0.5, Requirements: ["can_use_portals"]
Secret Passages
// Hidden passage
secret := spatial.CreateSecretPassageConnection(
    "secret-passage", "library", "hidden-chamber", 1.0,
    []string{"found_secret", "has_key"}, // Custom requirements
)
// Cost: 1.0, Reversible: true, Requirements: custom
Layout Patterns
Tower Layout (Vertical Stacking)

Perfect for multi-floor buildings:

// Create tower floors
floors := make([]spatial.Room, 5)
for i := 0; i < 5; i++ {
    floors[i] = spatial.NewBasicRoom(spatial.BasicRoomConfig{
        ID:   fmt.Sprintf("floor-%d", i+1),
        Type: "floor",
        Grid: spatial.NewSquareGrid(spatial.SquareGridConfig{Width: 20, Height: 20}),
        EventBus: eventBus,
    })
    orchestrator.AddRoom(floors[i])
}

// Connect floors with stairs
for i := 0; i < len(floors)-1; i++ {
    stairs := spatial.CreateStairsConnection(
        fmt.Sprintf("stairs-%d-%d", i+1, i+2),
        floors[i].GetID(),
        floors[i+1].GetID(),
        2.0,
        true, // going up
    )
    orchestrator.AddConnection(stairs)
}

// Set tower layout
orchestrator.SetLayout(spatial.LayoutTypeTower)
Branching Layout (Hub and Spoke)

Great for dungeons with a central hub:

// Create central hub
hub := spatial.NewBasicRoom(spatial.BasicRoomConfig{
    ID:   "central-hub",
    Type: "chamber",
    Grid: spatial.NewSquareGrid(spatial.SquareGridConfig{Width: 30, Height: 30}),
    EventBus: eventBus,
})
orchestrator.AddRoom(hub)

// Create branching rooms
branches := []string{"north-wing", "south-wing", "east-wing", "west-wing"}
positions := []spatial.Position{
    {X: 15, Y: 0},   // North exit
    {X: 15, Y: 29},  // South exit
    {X: 29, Y: 15},  // East exit
    {X: 0, Y: 15},   // West exit
}

for i, branchID := range branches {
    // Create branch room
    branch := spatial.NewBasicRoom(spatial.BasicRoomConfig{
        ID:   branchID,
        Type: "chamber",
        Grid: spatial.NewSquareGrid(spatial.SquareGridConfig{Width: 20, Height: 20}),
        EventBus: eventBus,
    })
    orchestrator.AddRoom(branch)
    
    // Connect to hub
    door := spatial.CreateDoorConnection(
        fmt.Sprintf("door-to-%s", branchID),
        "central-hub",
        branchID,
        1.0,
    )
    orchestrator.AddConnection(door)
}

orchestrator.SetLayout(spatial.LayoutTypeBranching)
Advanced Features
Pathfinding Between Rooms
// Find path between rooms
path, err := orchestrator.FindPath("entrance-hall", "treasure-room", hero)
if err != nil {
    log.Fatal(err)
}

// path contains room IDs in order: ["entrance-hall", "treasure-room"]
fmt.Printf("Path: %v\n", path)
Entity Tracking
// Track entity location
roomID, exists := orchestrator.GetEntityRoom("hero")
if exists {
    fmt.Printf("Hero is in room: %s\n", roomID)
}

// Get all entities in orchestrator
allRooms := orchestrator.GetAllRooms()
for roomID, room := range allRooms {
    entities := room.GetAllEntities()
    fmt.Printf("Room %s has %d entities\n", roomID, len(entities))
}
Connection Management
// Get all connections for a room
connections := orchestrator.GetRoomConnections("entrance-hall")
for _, conn := range connections {
    fmt.Printf("Connection: %s (%s)\n", conn.GetID(), conn.GetConnectionType())
}

// Check if entity can move through connection
canMove := orchestrator.CanMoveEntityBetweenRooms("hero", "room-a", "room-b", "door-1")
Layout Selection

SetLayout stores one of the caller-selected LayoutType values and publishes LayoutChangedTopic for observers. The module does not calculate room positions or layout metrics.

Event System Integration

Orchestrator topics are optional observer notifications. Managed mutations return their results directly; events are not a hidden result channel. TransitionEntity continues to publish the active room-transition observation:

spatial.EntityRoomTransitionTopic.On(eventBus).Subscribe(
    context.Background(),
    func(ctx context.Context, event spatial.EntityRoomTransitionEvent) error {
        fmt.Printf("Entity %s left %s for %s\n", event.EntityID, event.FromRoom, event.ToRoom)
        return nil
    },
)
Complete Multi-Room Example
func CreateDungeonExample() {
    // Setup
    eventBus := events.NewEventBus()
    orchestrator := spatial.NewBasicRoomOrchestrator(spatial.BasicRoomOrchestratorConfig{
        ID:     "dungeon-orchestrator",
        Type:   "orchestrator",
        Layout: spatial.LayoutTypeBranching,
    })
    orchestrator.ConnectToEventBus(eventBus) // Optional observer publication.
    
    // Create rooms
    entrance := spatial.NewBasicRoom(spatial.BasicRoomConfig{
        ID:       "entrance",
        Type:     "chamber",
        Grid:     spatial.NewSquareGrid(spatial.SquareGridConfig{Width: 20, Height: 20}),
    })
    
    corridor := spatial.NewBasicRoom(spatial.BasicRoomConfig{
        ID:       "corridor",
        Type:     "hallway",
        Grid:     spatial.NewSquareGrid(spatial.SquareGridConfig{Width: 30, Height: 10}),
    })
    
    treasureRoom := spatial.NewBasicRoom(spatial.BasicRoomConfig{
        ID:       "treasure",
        Type:     "chamber",
        Grid:     spatial.NewSquareGrid(spatial.SquareGridConfig{Width: 15, Height: 15}),
    })
    entrance.ConnectToEventBus(eventBus) // Optional room-event publication.
    corridor.ConnectToEventBus(eventBus)
    treasureRoom.ConnectToEventBus(eventBus)

    // Add rooms to orchestrator
    orchestrator.AddRoom(entrance)
    orchestrator.AddRoom(corridor)
    orchestrator.AddRoom(treasureRoom)
    
    // Create connections
    door1 := spatial.CreateDoorConnection(
        "entrance-to-corridor", "entrance", "corridor", 1.0,
    )
    
    door2 := spatial.CreateDoorConnection(
        "corridor-to-treasure", "corridor", "treasure", 1.0,
    )
    
    orchestrator.AddConnection(door1)
    orchestrator.AddConnection(door2)
    
    // Place entities through the managed seam.
    hero := &Character{id: "hero", entityType: "character"}
    monster := &Character{id: "orc", entityType: "monster"}
    _, _ = orchestrator.PlaceEntity(&spatial.PlaceEntityInput{
        RoomID: "entrance", Entity: hero, Position: spatial.Position{X: 5, Y: 5},
    })
    _, _ = orchestrator.PlaceEntity(&spatial.PlaceEntityInput{
        RoomID: "treasure", Entity: monster, Position: spatial.Position{X: 10, Y: 10},
    })

    // Each abstract transition returns the entity for caller-chosen placement.
    toCorridor, _ := orchestrator.TransitionEntity(&spatial.TransitionEntityInput{
        EntityID: "hero", FromRoom: "entrance", ToRoom: "corridor",
        ConnectionID: "entrance-to-corridor",
    })
    _, _ = orchestrator.PlaceEntity(&spatial.PlaceEntityInput{
        RoomID: "corridor", Entity: toCorridor.Entity, Position: spatial.Position{X: 1, Y: 1},
    })
    toTreasure, _ := orchestrator.TransitionEntity(&spatial.TransitionEntityInput{
        EntityID: "hero", FromRoom: "corridor", ToRoom: "treasure",
        ConnectionID: "corridor-to-treasure",
    })
    _, _ = orchestrator.PlaceEntity(&spatial.PlaceEntityInput{
        RoomID: "treasure", Entity: toTreasure.Entity, Position: spatial.Position{X: 1, Y: 1},
    })

    // Hero is physically placed and indexed in the treasure room.
    heroRoom, _ := orchestrator.GetEntityRoom("hero")
    fmt.Printf("Hero is in: %s\n", heroRoom) // "treasure"
}

Entity Placement

Implementing Placeable

For entities that need spatial properties:

type Monster struct {
    id   string
    size int
    solid bool
}

func (m *Monster) GetID() string              { return m.id }
func (m *Monster) GetType() string            { return "monster" }
func (m *Monster) GetSize() int               { return m.size }
func (m *Monster) BlocksMovement() bool       { return m.solid }
func (m *Monster) BlocksLineOfSight() bool    { return m.solid }
Placement Rules
  • Entities cannot be placed on positions that would conflict with blocking entities
  • The same entity can be moved to different positions
  • Position validity depends on the grid system
  • Events are automatically published for placement changes

Event System Integration

Spatial notifications use typed topics. Room topics include EntityPlacedTopic, EntityMovedTopic, EntityRemovedTopic, and RoomCreatedTopic. Orchestrator topics include RoomAddedTopic, RoomRemovedTopic, ConnectionAddedTopic, ConnectionRemovedTopic, EntityRoomTransitionTopic, and LayoutChangedTopic.

spatial.EntityPlacedTopic.On(eventBus).Subscribe(
    context.Background(),
    func(ctx context.Context, event spatial.EntityPlacedEvent) error {
        fmt.Printf("Entity %s placed at %v in room %s\n", event.EntityID, event.Position, event.RoomID)
        return nil
    },
)

Queries are synchronous calls through SpatialQueryHandler; they do not publish notifications.

Query System

The spatial module provides two ways to perform spatial queries:

Direct Room Queries
// Get entities within range
entities := room.GetEntitiesInRange(center, radius)

// Get positions within range
positions := room.GetPositionsInRange(center, radius)

// Line of sight
losPositions := room.GetLineOfSight(from, to)
blocked := room.IsLineOfSightBlocked(from, to)
Query Handler

For queries routed across registered rooms, construct QueryUtils with the direct handler and provide any entity-type vocabulary at the call site:

queryUtils := spatial.NewQueryUtils(queryHandler)
filter := spatial.NewSimpleEntityFilter().
    WithEntityTypes("ally", "opponent").
    WithExcludeIDs("entity-1")
entities, err := queryUtils.QueryEntitiesInRange(ctx, center, radius, roomID, filter)

valid, path, distance, err := queryUtils.QueryMovement(ctx, entity, from, to, roomID)
positions, blocked, err := queryUtils.QueryLineOfSight(ctx, from, to, roomID)

API Reference

Core Interfaces
Grid Interface
type Grid interface {
    GetShape() GridShape
    IsValidPosition(pos Position) bool
    GetDimensions() Dimensions
    Distance(from, to Position) float64
    GetNeighbors(pos Position) []Position
    IsAdjacent(pos1, pos2 Position) bool
    GetLineOfSight(from, to Position) []Position
    GetPositionsInRange(center Position, radius float64) []Position
}
Distance Field

Field floods outward from one or more sources over Grid.GetNeighbors, Dijkstra by Cost, and answers with the distance and predecessor of every cell it reached. Reach is a field with a Limit, a path is PathTo on the same field, and a blast that spreads around corners is a field under a walls-only predicate. What a cell means is not spatial's: it reaches the field through Passable and Cost.

func Field(g Grid, in FieldInput) (FieldOutput, error)

type FieldInput struct {
    Sources  []Position                   // flood from all of these at once
    Passable func(from, to Position) bool // required; nil fails closed
    Cost     func(from, to Position) int  // nil means one per step
    Limit    int                          // stop past this distance; 0 = unbounded
}

type FieldOutput struct {
    Dist map[Position]int      // every reached cell, sources at 0
    Prev map[Position]Position // the cell each was entered from; absent for sources
}

// PathTo reads the field backwards: the path excludes the source and ends at goal.
func (f FieldOutput) PathTo(goal Position) ([]Position, bool)
Placed footprint geometry

PlacedCoverage rasterises a freely placed rectangular footprint over an explicit universe of axial cells. TraceFootprint tests a closed segment against the same rectangle without depending on a grid. Both use the caller's continuous plane and report geometry only: thresholds, movement, sight, cover, and whether a prop occupies room cells remain caller policy.

HexEmbedding.CellWidth, points, and box dimensions all use the caller's chosen unit; spatial has no notion of feet. Plane X runs east and Y runs south. Facing maps local +X to (cos(a), sin(a)), so positive 90 degrees points south. Box.D runs along local X, Box.W across local Y, and LocalOffset shifts the box centre in those local axes before rotation and translation by Origin.

type FootprintPlacement struct {
    Footprint   Footprint
    Origin      Point
    Facing      float64
    LocalOffset Point
}

type PlacedCoverageInput struct {
    Embedding HexEmbedding
    Placement FootprintPlacement
    Cells     []Position // exact finite integral axial cells to inspect
}

func PlacedCoverage(in PlacedCoverageInput) (CoverageOutput, error)

type FootprintTraceInput struct {
    Placement FootprintPlacement
    From, To  Point
}

type FootprintTraceOutput struct {
    Contact  bool    // meets the closed rectangle
    Interior bool    // positive-length portion lies strictly inside
    Enter    float64 // contact interval in [0,1]
    Leave    float64
}

func TraceFootprint(in FootprintTraceInput) (FootprintTraceOutput, error)

Coverage misses are absent from the output map; duplicates are idempotent. An empty candidate universe returns a non-nil empty map after validating the embedding and placement. Callers provide bounded candidate sets: the query does not infer floor, void, map membership, or nearest cells. Invalid or non-finite geometry, widths, endpoints, and fractional/non-finite cells return an error and never a partial coverage map.

For traces, edge overlap and a corner touch are contact but not interior. A stationary point inside or on the rectangle is contact with interval [0,0] and is not interior; a stationary point outside is a miss. Reversing a segment maps the interval to [1-Leave, 1-Enter].

emb := spatial.NewHexEmbedding(spatial.HexEmbeddingConfig{CellWidth: 5})
placement := spatial.FootprintPlacement{
    Footprint: spatial.Footprint{Box: &spatial.Box{W: 4, D: 10}},
    Origin: spatial.Point{X: 3.25, Y: -1.75},
    Facing: 37,
}
coverage, err := spatial.PlacedCoverage(spatial.PlacedCoverageInput{
    Embedding: emb,
    Placement: placement,
    Cells: []spatial.Position{{}, {X: 1}, {Y: -1}},
})
trace, err := spatial.TraceFootprint(spatial.FootprintTraceInput{
    Placement: placement,
    From: spatial.Point{X: -5},
    To: spatial.Point{X: 8},
})

Coverage remains the cell-anchored compatibility entry point. It converts the anchor cell through CellCentre, supplies a bounded candidate set from the grid, and delegates to PlacedCoverage. AnchorAtCentre centres the box on the cell; AnchorAtEdge puts its near edge one cell inradius along Facing. Existing spell thresholds remain above this API.

These queries do not make props occupy cells in BasicRoom, persist authored footprints, load meshes, or add scale, height, and polygon support.

Shared sight-lane evaluation

SightLanes exposes the same direct and progress-making-neighbour lane calculation used by BasicRoom, while callers supply obstruction facts. Along reports hard obstructions that cannot be bypassed and soft obstructions that an alternate lane may bypass; both block the lane on which they appear. At reports an opaque alternate origin. It is not a movement or standing query.

Each Along call receives one canonical Ray, oriented from From toward To. Treat the ray as read-only and do not retain it. The query retains no callbacks and takes no locks, so callers own a stable view for its duration. Missing collaborators and non-finite endpoints return the documented errors; any callback error is returned with a zero output rather than being interpreted as clear or blocked sight.

Continuous footprints compose without occupying a BasicRoom cell:

type footprintSight struct {
    emb       spatial.HexEmbedding
    placement spatial.FootprintPlacement
}

func (f footprintSight) Along(in spatial.SightLaneInput) (spatial.SightLaneOutput, error) {
    trace, err := spatial.TraceFootprint(spatial.FootprintTraceInput{
        Placement: f.placement,
        From:      f.emb.CellCentre(in.From),
        To:        f.emb.CellCentre(in.To),
    })
    return spatial.SightLaneOutput{SoftBlocked: trace.Interior}, err
}

func (f footprintSight) At(in spatial.SightCellInput) (spatial.SightCellOutput, error) {
    point := f.emb.CellCentre(in.At)
    trace, err := spatial.TraceFootprint(spatial.FootprintTraceInput{
        Placement: f.placement,
        From:      point,
        To:        point,
    })
    return spatial.SightCellOutput{Blocked: trace.Contact}, err
}

obstructions := footprintSight{
    emb: spatial.NewHexEmbedding(spatial.HexEmbeddingConfig{CellWidth: 5}),
    placement: spatial.FootprintPlacement{
        Footprint: spatial.Footprint{Box: &spatial.Box{W: 30, D: 0.2}},
    },
}
result, err := spatial.SightLanes(spatial.SightLanesInput{
    Grid:         spatial.NewAxialHexGrid(spatial.AxialHexGridConfig{SpanWidth: 9, SpanHeight: 9}),
    From:         spatial.Position{X: -2},
    To:           spatial.Position{X: 2},
    Obstructions: obstructions,
})

This example demonstrates geometry composition only; live prop ownership, standing thresholds, cover rules, and persistence remain caller policy. BasicRoom.IsLineOfSightBlocked delegates through an internal obstruction reader while holding its existing read lock, giving its callbacks a stable view.

Room Interface
type Room interface {
    core.Entity
    GetGrid() Grid
    PlaceEntity(entity core.Entity, pos Position) error
    MoveEntity(entityID string, newPos Position) error
    RemoveEntity(entityID string) error
    GetEntitiesAt(pos Position) []core.Entity
    GetEntityPosition(entityID string) (Position, bool)
    GetAllEntities() map[string]core.Entity
    GetEntitiesInRange(center Position, radius float64) []core.Entity
    IsPositionOccupied(pos Position) bool
    CanPlaceEntity(entity core.Entity, pos Position) bool
    GetPositionsInRange(center Position, radius float64) []Position
    GetLineOfSight(from, to Position) []Position
    IsLineOfSightBlocked(from, to Position) bool
}
RoomOrchestrator Interface
type RoomOrchestrator interface {
    core.Entity
    EventBusIntegration
    
    // Room management
    AddRoom(room Room) error
    RemoveRoom(roomID string) error
    GetRoom(roomID string) (Room, bool)
    GetAllRooms() map[string]Room
    
    // Connection management
    AddConnection(connection Connection) error
    RemoveConnection(connectionID string) error
    GetConnection(connectionID string) (Connection, bool)
    GetRoomConnections(roomID string) []Connection
    GetAllConnections() map[string]Connection
    
    // Entity movement
    MoveEntityBetweenRooms(entityID, fromRoom, toRoom, connectionID string) error
    CanMoveEntityBetweenRooms(entityID, fromRoom, toRoom, connectionID string) bool
    GetEntityRoom(entityID string) (string, bool)
    
    // Pathfinding
    FindPath(fromRoom, toRoom string, entity core.Entity) ([]string, error)
    
    // Layout management
    GetLayout() LayoutType
    SetLayout(layout LayoutType) error
}
Connection Interface
type Connection interface {
    core.Entity
    
    GetConnectionType() ConnectionType
    GetFromRoom() string
    GetToRoom() string
    GetFromPosition() Position
    GetToPosition() Position
    IsPassable(entity core.Entity) bool
    GetTraversalCost(entity core.Entity) float64
    IsReversible() bool
    GetRequirements() []string
}
Grid Constructors
// Square grid
func NewSquareGrid(config SquareGridConfig) *SquareGrid

// Hex grid
func NewHexGrid(config HexGridConfig) *HexGrid

// Gridless room
func NewGridlessRoom(config GridlessConfig) *GridlessRoom
Room Constructor
func NewBasicRoom(config BasicRoomConfig) *BasicRoom
Query System
// Query handler
func NewSpatialQueryHandler() *SpatialQueryHandler

// Query utilities
func NewQueryUtils(queryHandler *SpatialQueryHandler) *QueryUtils

Examples

Complete Combat Scenario
package main

import (
    "context"
    "fmt"
    "log"
    
    "github.com/KirkDiggler/rpg-toolkit/core"
    "github.com/KirkDiggler/rpg-toolkit/events"
    "github.com/KirkDiggler/rpg-toolkit/tools/spatial"
)

type Combatant struct {
    id        string
    name      string
    entityType string
    size      int
    blocking  bool
}

func (c *Combatant) GetID() string              { return c.id }
func (c *Combatant) GetType() string            { return c.entityType }
func (c *Combatant) GetSize() int               { return c.size }
func (c *Combatant) BlocksMovement() bool       { return c.blocking }
func (c *Combatant) BlocksLineOfSight() bool    { return c.blocking }

func main() {
    // Setup
    eventBus := events.NewBus()
    
    grid := spatial.NewSquareGrid(spatial.SquareGridConfig{
        Width:  20,
        Height: 20,
    })
    
    room := spatial.NewBasicRoom(spatial.BasicRoomConfig{
        ID:       "combat-room",
        Type:     "dungeon",
        Grid:     grid,
        EventBus: eventBus,
    })
    
    queryHandler := spatial.NewSpatialQueryHandler()
    queryHandler.RegisterRoom(room)
    
    queryUtils := spatial.NewQueryUtils(queryHandler)
    
    // Create combatants
    hero := &Combatant{
        id:        "hero",
        name:      "Hero",
        entityType: "character",
        size:      1,
        blocking:  true,
    }
    
    orc := &Combatant{
        id:        "orc",
        name:      "Orc",
        entityType: "monster",
        size:      1,
        blocking:  true,
    }
    
    goblin := &Combatant{
        id:        "goblin",
        name:      "Goblin",
        entityType: "monster",
        size:      1,
        blocking:  true,
    }
    
    // Place entities
    room.PlaceEntity(hero, spatial.Position{X: 5, Y: 5})
    room.PlaceEntity(orc, spatial.Position{X: 8, Y: 8})
    room.PlaceEntity(goblin, spatial.Position{X: 12, Y: 6})
    
    // Query nearby enemies
    ctx := context.Background()
    enemyFilter := spatial.NewSimpleEntityFilter().WithEntityTypes("monster")
    
    nearbyEnemies, err := queryUtils.QueryEntitiesInRange(
        ctx, 
        spatial.Position{X: 5, Y: 5}, // Hero's position
        10.0,                        // 10 unit range
        "combat-room",
        enemyFilter,
    )
    
    if err != nil {
        log.Fatal(err)
    }
    
    fmt.Printf("Enemies within 10 units of hero: %d\n", len(nearbyEnemies))
    
    // Check line of sight to orc
    losPositions, blocked, err := queryUtils.QueryLineOfSight(
        ctx,
        spatial.Position{X: 5, Y: 5},  // Hero
        spatial.Position{X: 8, Y: 8},  // Orc
        "combat-room",
    )
    
    if err != nil {
        log.Fatal(err)
    }
    
    fmt.Printf("Line of sight blocked: %v\n", blocked)
    fmt.Printf("LOS path length: %d\n", len(losPositions))
    
    // Validate movement
    valid, path, distance, err := queryUtils.QueryMovement(
        ctx,
        hero,
        spatial.Position{X: 5, Y: 5},  // From
        spatial.Position{X: 7, Y: 7},  // To
        "combat-room",
    )
    
    if err != nil {
        log.Fatal(err)
    }
    
    fmt.Printf("Movement valid: %v, distance: %.2f\n", valid, distance)
    fmt.Printf("Path length: %d\n", len(path))
    
    // Move hero if valid
    if valid {
        err = room.MoveEntity("hero", spatial.Position{X: 7, Y: 7})
        if err != nil {
            log.Fatal(err)
        }
        fmt.Println("Hero moved successfully!")
    }
}
Grid System Comparison
func compareGridSystems() {
    // Same positions for comparison
    from := spatial.Position{X: 2, Y: 2}
    to := spatial.Position{X: 6, Y: 6}
    
    // Square grid
    squareGrid := spatial.NewSquareGrid(spatial.SquareGridConfig{
        Width: 10, Height: 10,
    })
    
    // Hex grid
    hexGrid := spatial.NewHexGrid(spatial.HexGridConfig{
        Width: 10, Height: 10, PointyTop: true,
    })
    
    // Gridless
    gridlessRoom := spatial.NewGridlessRoom(spatial.GridlessConfig{
        Width: 10, Height: 10,
    })
    
    // Compare distances
    fmt.Printf("Distance from %v to %v:\n", from, to)
    fmt.Printf("Square Grid: %.2f\n", squareGrid.Distance(from, to))
    fmt.Printf("Hex Grid: %.2f\n", hexGrid.Distance(from, to))
    fmt.Printf("Gridless: %.2f\n", gridlessRoom.Distance(from, to))
    
    // Compare neighbors
    pos := spatial.Position{X: 5, Y: 5}
    fmt.Printf("\nNeighbors of %v:\n", pos)
    fmt.Printf("Square Grid: %d\n", len(squareGrid.GetNeighbors(pos)))
    fmt.Printf("Hex Grid: %d\n", len(hexGrid.GetNeighbors(pos)))
    fmt.Printf("Gridless: %d\n", len(gridlessRoom.GetNeighbors(pos)))
}

API Reference

Core Interfaces
Grid Interface
type Grid interface {
    GetShape() GridShape
    IsValidPosition(pos Position) bool
    GetDimensions() Dimensions
    Distance(from, to Position) float64
    GetNeighbors(pos Position) []Position
    IsAdjacent(pos1, pos2 Position) bool
    GetLineOfSight(from, to Position) []Position
    GetPositionsInRange(center Position, radius float64) []Position
}
Room Interface
type Room interface {
    core.Entity
    GetGrid() Grid
    PlaceEntity(entity core.Entity, pos Position) error
    MoveEntity(entityID string, newPos Position) error
    RemoveEntity(entityID string) error
    GetEntitiesAt(pos Position) []core.Entity
    GetEntityPosition(entityID string) (Position, bool)
    GetAllEntities() map[string]core.Entity
    GetEntitiesInRange(center Position, radius float64) []core.Entity
    IsPositionOccupied(pos Position) bool
    CanPlaceEntity(entity core.Entity, pos Position) bool
    GetPositionsInRange(center Position, radius float64) []Position
    GetLineOfSight(from, to Position) []Position
    IsLineOfSightBlocked(from, to Position) bool
}
RoomOrchestrator Interface
type RoomOrchestrator interface {
    core.Entity
    EventBusIntegration
    
    // Room management
    AddRoom(room Room) error
    RemoveRoom(roomID string) error
    GetRoom(roomID string) (Room, bool)
    GetAllRooms() map[string]Room
    
    // Connection management
    AddConnection(connection Connection) error
    RemoveConnection(connectionID string) error
    GetConnection(connectionID string) (Connection, bool)
    GetRoomConnections(roomID string) []Connection
    GetAllConnections() map[string]Connection
    
    // Entity movement
    MoveEntityBetweenRooms(entityID, fromRoom, toRoom, connectionID string) error
    CanMoveEntityBetweenRooms(entityID, fromRoom, toRoom, connectionID string) bool
    GetEntityRoom(entityID string) (string, bool)
    
    // Pathfinding
    FindPath(fromRoom, toRoom string, entity core.Entity) ([]string, error)
    
    // Layout management
    GetLayout() LayoutType
    SetLayout(layout LayoutType) error
}
Connection Interface
type Connection interface {
    core.Entity
    
    GetConnectionType() ConnectionType
    GetFromRoom() string
    GetToRoom() string
    GetFromPosition() Position
    GetToPosition() Position
    IsPassable(entity core.Entity) bool
    GetTraversalCost(entity core.Entity) float64
    IsReversible() bool
    GetRequirements() []string
}
Constructors
Grid Constructors
// Square grid
func NewSquareGrid(config SquareGridConfig) *SquareGrid

// Hex grid
func NewHexGrid(config HexGridConfig) *HexGrid

// Gridless room
func NewGridlessRoom(config GridlessConfig) *GridlessRoom
Room Constructor
func NewBasicRoom(config BasicRoomConfig) *BasicRoom
Orchestrator Constructor
func NewBasicRoomOrchestrator(config BasicRoomOrchestratorConfig) *BasicRoomOrchestrator
Connection Constructor
func NewBasicConnection(config BasicConnectionConfig) *BasicConnection
Connection Helper Functions

The module provides helper functions for creating common connection types:

// Door connection (bidirectional, cost 1.0)
func CreateDoorConnection(id, fromRoom, toRoom string, cost float64) *BasicConnection

// Stair connection (bidirectional, cost 2.0, may have climb requirement)
func CreateStairsConnection(id, fromRoom, toRoom string, cost float64, goingUp bool) *BasicConnection

// Secret passage (bidirectional, cost 1.0, requires discovery)
func CreateSecretPassageConnection(id, fromRoom, toRoom string, cost float64, requirements []string) *BasicConnection

// Portal connection (configurable direction, cost 0.5, requires portal use)
func CreatePortalConnection(id, fromRoom, toRoom string, cost float64, bidirectional bool) *BasicConnection

// Bridge connection (bidirectional, cost 1.0)
func CreateBridgeConnection(id, fromRoom, toRoom string, cost float64) *BasicConnection

// Tunnel connection (bidirectional, cost 1.5)
func CreateTunnelConnection(id, fromRoom, toRoom string, cost float64) *BasicConnection
Query System
// Query handler
func NewSpatialQueryHandler() *SpatialQueryHandler

// Query utilities
func NewQueryUtils(queryHandler *SpatialQueryHandler) *QueryUtils
Typed Event Topics

Room and orchestrator observations are exposed as typed topics:

spatial.EntityPlacedTopic
spatial.EntityMovedTopic
spatial.EntityRemovedTopic
spatial.RoomCreatedTopic
spatial.RoomAddedTopic
spatial.RoomRemovedTopic
spatial.ConnectionAddedTopic
spatial.ConnectionRemovedTopic
spatial.EntityRoomTransitionTopic
spatial.LayoutChangedTopic

Spatial queries use direct handler calls and have no event constants.

Types and Constants

Managed entity membership accepts core.EntityID. Spatial-specific identity remains available as RoomID, ConnectionID, and OrchestratorID.

Connection Types
const (
    ConnectionTypeDoor    ConnectionType = "door"
    ConnectionTypeStairs  ConnectionType = "stairs"
    ConnectionTypePassage ConnectionType = "passage"
    ConnectionTypePortal  ConnectionType = "portal"
    ConnectionTypeBridge  ConnectionType = "bridge"
    ConnectionTypeTunnel  ConnectionType = "tunnel"
)
Layout Types
const (
    LayoutTypeTower     LayoutType = "tower"
    LayoutTypeBranching LayoutType = "branching"
    LayoutTypeGrid      LayoutType = "grid"
    LayoutTypeOrganic   LayoutType = "organic"
)

Testing

The spatial module includes comprehensive tests. To run them:

go test ./...
Test Structure
  • *_test.go - Unit tests for each grid system
  • room_test.go - Room functionality tests
  • query_handler_test.go - Query system tests
  • examples_test.go - Integration examples and usage patterns
Mock Entities

For testing, use the provided mock entity pattern:

type MockEntity struct {
    id       string
    entityType string
    size     int
    blocksMovement bool
    blocksLOS  bool
}

func (m *MockEntity) GetID() string              { return m.id }
func (m *MockEntity) GetType() string            { return m.entityType }
func (m *MockEntity) GetSize() int               { return m.size }
func (m *MockEntity) BlocksMovement() bool       { return m.blocksMovement }
func (m *MockEntity) BlocksLineOfSight() bool    { return m.blocksLOS }

Advanced Usage Patterns

Error Handling and Validation
// Always check for errors when working with orchestrators
orchestrator := spatial.NewBasicRoomOrchestrator(config)

// Validate room addition
if err := orchestrator.AddRoom(room); err != nil {
    log.Printf("Failed to add room: %v", err)
    return err
}

// Validate connection requirements
if !orchestrator.CanMoveEntityBetweenRooms(entityID, fromRoom, toRoom, connectionID) {
    log.Printf("Entity %s cannot move through connection %s", entityID, connectionID)
    return errors.New("movement blocked")
}

// Safe entity movement with rollback
if err := orchestrator.MoveEntityBetweenRooms(entityID, fromRoom, toRoom, connectionID); err != nil {
    log.Printf("Movement failed: %v", err)
    // Handle failure (entity remains in original room)
}
Event-Driven Observer Logic
spatial.EntityRoomTransitionTopic.On(eventBus).Subscribe(
    context.Background(),
    func(ctx context.Context, event spatial.EntityRoomTransitionEvent) error {
        return observeRoomDeparture(event.EntityID, event.FromRoom, event.ToRoom)
    },
)

spatial.ConnectionAddedTopic.On(eventBus).Subscribe(
    context.Background(),
    func(ctx context.Context, event spatial.ConnectionAddedEvent) error {
        return updateMapConnection(event.ConnectionID, event.FromRoom, event.ToRoom)
    },
)
Dynamic Connection Management
// Create locked door that can be unlocked
door := spatial.CreateDoorConnection("treasure-door", "hallway", "treasure-room", 1.0)
door.SetPassable(false) // Initially locked
door.AddRequirement("has_key")
orchestrator.AddConnection(door)

// Unlock door when key is found
func unlockDoor(orchestrator spatial.RoomOrchestrator, connectionID string) error {
    if conn, exists := orchestrator.GetConnection(connectionID); exists {
        if basicConn, ok := conn.(*spatial.BasicConnection); ok {
            basicConn.SetPassable(true)
            basicConn.RemoveRequirement("has_key")
            return nil
        }
    }
    return errors.New("connection not found")
}
Performance Optimization
// For large orchestrators, consider batching operations
func addMultipleRooms(orchestrator spatial.RoomOrchestrator, rooms []spatial.Room) error {
    // Add rooms in batch to reduce event overhead
    for _, room := range rooms {
        if err := orchestrator.AddRoom(room); err != nil {
            return fmt.Errorf("failed to add room %s: %w", room.GetID(), err)
        }
    }
    return nil
}

// Use pathfinding sparingly for large orchestrators
func findOptimalPath(orchestrator spatial.RoomOrchestrator, entity core.Entity, fromRoom, toRoom string) ([]string, error) {
    // Cache paths for frequently used routes
    cacheKey := fmt.Sprintf("%s-%s-%s", entity.GetType(), fromRoom, toRoom)
    if cachedPath, exists := pathCache[cacheKey]; exists {
        return cachedPath, nil
    }
    
    path, err := orchestrator.FindPath(fromRoom, toRoom, entity)
    if err == nil {
        pathCache[cacheKey] = path
    }
    return path, err
}
Layout-Specific Patterns
// Tower layout - vertical progression
func createTowerDungeon(orchestrator spatial.RoomOrchestrator, floors int) {
    orchestrator.SetLayout(spatial.LayoutTypeTower)
    
    for i := 0; i < floors; i++ {
        // Create floor room
        floor := createFloorRoom(i)
        orchestrator.AddRoom(floor)
        
        // Connect to previous floor
        if i > 0 {
            stairs := spatial.CreateStairsConnection(
                fmt.Sprintf("stairs-%d", i),
                fmt.Sprintf("floor-%d", i-1),
                fmt.Sprintf("floor-%d", i),
                2.0,
                true, // going up
            )
            orchestrator.AddConnection(stairs)
        }
    }
}

// Branching layout - hub and spoke
func createBranchingDungeon(orchestrator spatial.RoomOrchestrator, hubRoom spatial.Room, branches []spatial.Room) {
    orchestrator.SetLayout(spatial.LayoutTypeBranching)
    orchestrator.AddRoom(hubRoom)
    
    for i, branch := range branches {
        orchestrator.AddRoom(branch)
        
        // Connect each branch to hub
        door := spatial.CreateDoorConnection(
            fmt.Sprintf("hub-to-branch-%d", i), hubRoom.GetID(), branch.GetID(), 1.0,
        )
        orchestrator.AddConnection(door)
    }
}
Testing Orchestrator Behavior
func TestOrchestratorBehavior(t *testing.T) {
    // Setup
    orchestrator := spatial.NewBasicRoomOrchestrator(spatial.BasicRoomOrchestratorConfig{
        ID: "test-orchestrator",
    })
    
    // Create test scenario
    room1 := createTestRoom("room1")
    room2 := createTestRoom("room2")
    orchestrator.AddRoom(room1)
    orchestrator.AddRoom(room2)
    
    door := spatial.CreateDoorConnection("door1", "room1", "room2", 1.0)
    orchestrator.AddConnection(door)
    
    // Test managed placement and transition.
    entity := createTestEntity("hero")
    _, err := orchestrator.PlaceEntity(&spatial.PlaceEntityInput{
        RoomID: "room1", Entity: entity, Position: spatial.Position{X: 5, Y: 5},
    })
    assert.NoError(t, err)
    roomID, exists := orchestrator.GetEntityRoom("hero")
    assert.True(t, exists)
    assert.Equal(t, "room1", roomID)

    transitioned, err := orchestrator.TransitionEntity(&spatial.TransitionEntityInput{
        EntityID: "hero", FromRoom: "room1", ToRoom: "room2", ConnectionID: "door1",
    })
    assert.NoError(t, err)
    _, exists = orchestrator.GetEntityRoom("hero")
    assert.False(t, exists, "transition is unplaced until managed placement")
    _, err = orchestrator.PlaceEntity(&spatial.PlaceEntityInput{
        RoomID: "room2", Entity: transitioned.Entity, Position: spatial.Position{X: 1, Y: 1},
    })
    assert.NoError(t, err)
    roomID, exists = orchestrator.GetEntityRoom("hero")
    assert.True(t, exists)
    assert.Equal(t, "room2", roomID)

    // Test pathfinding
    path, err := orchestrator.FindPath("room1", "room2", entity)
    assert.NoError(t, err)
    assert.Equal(t, []string{"room1", "room2"}, path)
}

Integration with Other Modules

The spatial module integrates seamlessly with other RPG Toolkit modules:

  • Events: Automatic event publishing for spatial changes
  • Core: Uses core.Entity for type safety
  • Conditions: Spatial conditions (e.g., "within 30 feet")
  • Spells: Area of effect calculations
  • Combat: Movement and positioning
  • Resources: Movement costs and ability usage
  • Mechanics: Integration with game rule systems

Performance Considerations

  • Query Caching: The query system includes built-in caching
  • Event Throttling: Consider throttling high-frequency movement events
  • Large Grids: For very large areas, consider partitioning rooms
  • Memory Usage: Remove entities from rooms when no longer needed

Contributing

When contributing to the spatial module:

  1. Ensure all tests pass
  2. Add tests for new functionality
  3. Follow the existing code patterns
  4. Update this README for new features
  5. Consider event system integration for new features

License

Part of the RPG Toolkit - see main repository for license information.

Documentation

Overview

Package spatial provides 2D positioning and movement infrastructure for entity placement and spatial queries without imposing game-specific rules.

Purpose: This package handles all spatial mathematics, collision detection, and movement validation without imposing any game-specific movement rules or combat mechanics. It provides the mathematical foundation for position-based game systems.

Scope:

  • 2D coordinate system with configurable units
  • Grid support (square, offset hex, axial hex, gridless)
  • Room-based spatial organization
  • Collision detection and spatial queries
  • Path validation, and distance fields over a grid (Field, PathTo)
  • Footprint geometry: freely placed box coverage over an explicit hex-cell universe and closed-segment contact/interior intervals (PlacedCoverage, TraceFootprint)
  • Caller-supplied obstruction evaluation over canonical sight lanes (SightLanes)
  • Cell-anchored footprint coverage for compatibility (Coverage)
  • Multi-room orchestration and connections
  • Distance calculations and area queries
  • Entity position tracking

Non-Goals:

  • Movement rules: Speed, difficult terrain are game-specific
  • Line of sight rules: Cover/concealment mechanics belong in games
  • Cell meaning: what a cell means (blocked, costly, burning) is the game's; spatial answers where and how far, and Field is the search over that
  • Thresholds: how much of a cell a footprint must cover to count is the game's; Coverage answers with fractions and has no opinion about half
  • Interaction ranges: Their meanings and effects are game-specific
  • Durable prop/entity occupancy: geometry queries do not place footprints into rooms or choose playable cells
  • 3D positioning: This is explicitly 2D only
  • Movement costs: Action economy is game-specific
  • Elevation: Height/flying is game-specific

Integration: This package integrates with:

  • behavior: Provides position queries for AI decisions
  • spawn: Validates entity placement
  • environments: Provides room infrastructure
  • events: Optionally publishes movement and room transition observations

Standalone rooms are directly mutable. Entity membership in rooms added to a BasicRoomOrchestrator is mutated through ManagedRoomMutator, whose verbs return spatial deltas as values. Events are observer-only and are never consumed by the orchestrator as a hidden result channel. Cross-room TransitionEntity returns the removed entity and leaves it unplaced until managed PlaceEntity chooses a destination position.

The spatial package is the foundation for any position-based mechanics but deliberately avoids encoding any game rules about how space is used.

Example:

// Create a room with square grid
room := spatial.NewBasicRoom(spatial.RoomConfig{
    ID:     "throne-room",
    Width:  40,
    Height: 30,
    Grid:   spatial.GridTypeSquare,
})

// Place entities
err := room.PlaceEntity("guard-1", spatial.Position{X: 10, Y: 5})
err = room.PlaceEntity("king", spatial.Position{X: 20, Y: 25})

// Query nearby entities
nearby := room.GetEntitiesWithinDistance(
    spatial.Position{X: 15, Y: 15},
    10.0, // 10 units radius
)

// Multi-room orchestration
orchestrator := spatial.NewBasicOrchestrator(spatial.OrchestratorConfig{})
orchestrator.AddRoom(room)
orchestrator.AddRoom(hallway)

// Connect rooms
door := spatial.NewDoorConnection("door-1", "throne-room", "hallway",
    spatial.Position{X: 40, Y: 15}, // Exit position
    spatial.Position{X: 0, Y: 5},   // Entry position
)
orchestrator.AddConnection(door)

Package spatial provides 2D spatial positioning and movement capabilities for RPG games.

Package spatial provides 2D spatial positioning and movement capabilities for RPG games.

Index

Examples

Constants

View Source
const (
	// GridTypeSquare represents square grid type in room data
	GridTypeSquare = "square"
	// GridTypeHex represents hexagonal grid type in room data
	GridTypeHex = "hex"
	// GridTypeGridless represents gridless type in room data
	GridTypeGridless = "gridless"
)

Grid type constants for room data

Variables

View Source
var (
	// ErrNoSightGrid indicates that SightLanes was called without a grid.
	ErrNoSightGrid = errors.New("spatial: sight lanes require a grid")
	// ErrNoSightObstructions indicates that SightLanes was called without obstruction reads.
	ErrNoSightObstructions = errors.New("spatial: sight lanes require obstruction reads")
	// ErrBadSightPosition indicates that a sight endpoint contains a non-finite coordinate.
	ErrBadSightPosition = errors.New("spatial: sight endpoints must be finite")
)
View Source
var (
	// EntityPlacedTopic publishes events when entities are placed in rooms
	EntityPlacedTopic = events.DefineTypedTopic[EntityPlacedEvent]("spatial.entity.placed")
	// EntityMovedTopic publishes events when entities are moved within or between rooms
	EntityMovedTopic = events.DefineTypedTopic[EntityMovedEvent]("spatial.entity.moved")
	// EntityRemovedTopic publishes events when entities are removed from rooms
	EntityRemovedTopic = events.DefineTypedTopic[EntityRemovedEvent]("spatial.entity.removed")

	// RoomCreatedTopic publishes events when rooms are created
	RoomCreatedTopic = events.DefineTypedTopic[RoomCreatedEvent]("spatial.room.created")

	// RoomAddedTopic publishes events when rooms are added to orchestrators
	RoomAddedTopic = events.DefineTypedTopic[RoomAddedEvent]("spatial.orchestrator.room_added")
	// RoomRemovedTopic publishes events when rooms are removed from orchestrators
	RoomRemovedTopic = events.DefineTypedTopic[RoomRemovedEvent]("spatial.orchestrator.room_removed")
	// ConnectionAddedTopic publishes events when connections are added between rooms
	ConnectionAddedTopic = events.DefineTypedTopic[ConnectionAddedEvent]("spatial.orchestrator.connection_added")
	// ConnectionRemovedTopic publishes events when connections are removed between rooms
	ConnectionRemovedTopic = events.DefineTypedTopic[ConnectionRemovedEvent]("spatial.orchestrator.connection_removed")
	// EntityRoomTransitionTopic publishes events when entities transition between rooms
	EntityRoomTransitionTopic = events.DefineTypedTopic[EntityRoomTransitionEvent]("entity.room_transition")
	// LayoutChangedTopic publishes events when orchestrator layouts change
	LayoutChangedTopic = events.DefineTypedTopic[LayoutChangedEvent]("spatial.orchestrator.layout_changed")
)
View Source
var ErrBadCellWidth = errors.New("spatial: cell width must be positive")

ErrBadCellWidth reports a cell width that is not a positive length. A hex with no width has no corners and no centre, so an embedding built from one answers nothing rather than answering something plausible.

View Source
var ErrBadCoverageCell = errors.New("spatial: invalid coverage cell")

ErrBadCoverageCell reports a non-finite, fractional, or unrepresentable cell.

View Source
var ErrBadFootprint = errors.New("spatial: footprint sides must be positive")

ErrBadFootprint reports a footprint whose sides are not positive lengths.

View Source
var ErrBadFootprintPlacement = errors.New("spatial: invalid footprint placement")

ErrBadFootprintPlacement reports non-finite or unrepresentable placed geometry.

View Source
var ErrBadFootprintTrace = errors.New("spatial: invalid footprint trace")

ErrBadFootprintTrace reports non-finite endpoints or unrepresentable arithmetic.

View Source
var ErrNoFootprint = errors.New("spatial: coverage needs a footprint")

ErrNoFootprint reports coverage asked for with no shape to rasterise.

View Source
var ErrNoPassable = errors.New("spatial: field needs a passable predicate")

ErrNoPassable is returned when Passable is nil; a field with no predicate would silently treat every cell as open, which is a zero value that lies.

View Source
var ErrNoSources = errors.New("spatial: field needs at least one source")

ErrNoSources is returned when a field is asked for with nothing to flood from.

Functions

func RunPositionValidationTests

func RunPositionValidationTests(t *testing.T, grid Grid)

RunPositionValidationTests runs common position validation tests for any Grid

Types

type AnchorRule added in v0.13.0

type AnchorRule int

AnchorRule says where a footprint sits relative to the cell it is placed at.

const (
	// AnchorAtCentre puts the footprint's centre on the anchor cell's centre.
	// The anchor cell is under the footprint.
	AnchorAtCentre AnchorRule = iota

	// AnchorAtEdge puts the footprint's near edge on the anchor cell's
	// boundary along Facing, so the anchor cell is never under it and the
	// footprint's depth is measured from that boundary outward.
	AnchorAtEdge
)

type AxialHexGrid added in v0.5.0

type AxialHexGrid struct {
	// contains filtered or unexported fields
}

AxialHexGrid implements an origin-centered hex grid whose Position values are axial coordinates: Position.X = Q, Position.Y = R, S = -(Q+R). It is intentionally distinct from HexGrid, which interprets the same fields as bounded offset column/row coordinates and supports orientation selection.

Distance uses the cube formula: (|ΔQ| + |ΔR| + |ΔS|) / 2, so adjacent hexes are distance 1 in every axial direction.

func NewAxialHexGrid added in v0.5.0

func NewAxialHexGrid(config AxialHexGridConfig) *AxialHexGrid

NewAxialHexGrid creates a new hex grid that treats Position.X/Y as axial Q/R coordinates. Use this instead of NewHexGrid when positions are stored in axial (not offset) form.

func (*AxialHexGrid) Distance added in v0.5.0

func (a *AxialHexGrid) Distance(from, to Position) float64

Distance returns the hex distance between two axial positions. Interprets Position.X as Q and Position.Y as R; derives S = -(Q+R). Uses the cube formula: (|ΔQ| + |ΔR| + |ΔS|) / 2.

func (*AxialHexGrid) GetDimensions added in v0.5.0

func (a *AxialHexGrid) GetDimensions() Dimensions

GetDimensions returns the configured bounding-box dimensions.

func (*AxialHexGrid) GetLineOfSight added in v0.5.0

func (a *AxialHexGrid) GetLineOfSight(from, to Position) []Position

GetLineOfSight returns hex positions along the line from from to to using cube-coordinate linear interpolation (standard hex line algorithm).

func (*AxialHexGrid) GetNeighbors added in v0.5.0

func (a *AxialHexGrid) GetNeighbors(pos Position) []Position

GetNeighbors returns the 6 axial positions adjacent to pos.

func (*AxialHexGrid) GetPositionsInCircle added in v0.5.0

func (a *AxialHexGrid) GetPositionsInCircle(circle Circle) []Position

GetPositionsInCircle returns all valid axial positions within the given circle.

func (*AxialHexGrid) GetPositionsInCone added in v0.5.0

func (a *AxialHexGrid) GetPositionsInCone(origin, direction Position, length, angle float64) []Position

GetPositionsInCone returns axial positions within a cone.

func (*AxialHexGrid) GetPositionsInLine added in v0.5.0

func (a *AxialHexGrid) GetPositionsInLine(from, to Position) []Position

GetPositionsInLine returns the positions along a line from from to to.

func (*AxialHexGrid) GetPositionsInRange added in v0.5.0

func (a *AxialHexGrid) GetPositionsInRange(center Position, radius float64) []Position

GetPositionsInRange returns all valid axial positions within radius hex steps of center.

func (*AxialHexGrid) GetPositionsInRectangle added in v0.5.0

func (a *AxialHexGrid) GetPositionsInRectangle(rect Rectangle) []Position

GetPositionsInRectangle returns all valid axial positions within the given rectangle (treating X as Q and Y as R for the bounding box).

func (*AxialHexGrid) GetShape added in v0.5.0

func (a *AxialHexGrid) GetShape() GridShape

GetShape returns GridShapeHex.

func (*AxialHexGrid) IsAdjacent added in v0.5.0

func (a *AxialHexGrid) IsAdjacent(pos1, pos2 Position) bool

IsAdjacent reports whether two positions are adjacent (hex distance <= 1).

func (*AxialHexGrid) IsValidPosition added in v0.5.0

func (a *AxialHexGrid) IsValidPosition(pos Position) bool

IsValidPosition reports true when (Q, R) falls within ±SpanWidth/2 along Q and ±SpanHeight/2 along R. The check is centered on the origin because axial hex coordinates are symmetric — positions can have negative Q or R. This differs from SquareGrid/HexGrid whose bounds start at (0,0).

type AxialHexGridConfig added in v0.5.0

type AxialHexGridConfig struct {
	SpanWidth  float64
	SpanHeight float64
}

AxialHexGridConfig holds configuration for creating an AxialHexGrid. SpanWidth and SpanHeight define the total span of valid axial coordinates. IsValidPosition checks are centered on the origin — a position (Q, R) is valid when Q ∈ [-SpanWidth/2, SpanWidth/2) and R ∈ [-SpanHeight/2, SpanHeight/2). This differs from SquareGrid/HexGrid, whose Width/Height are one-sided bounds starting at (0,0). Pass large values (e.g. 1000×1000) for encounter rooms where position bounds are not meaningful.

type BasicConnection

type BasicConnection struct {
	// contains filtered or unexported fields
}

BasicConnection implements the Connection interface (ADR-0015: Abstract Connections)

func CreateBridgeConnection

func CreateBridgeConnection(id, fromRoom, toRoom string, cost float64) *BasicConnection

CreateBridgeConnection creates a bridge connection that might be destructible

func CreateDoorConnection

func CreateDoorConnection(id, fromRoom, toRoom string, cost float64) *BasicConnection

CreateDoorConnection creates a bidirectional door connection between two rooms (ADR-0015: Abstract Connections)

func CreatePortalConnection

func CreatePortalConnection(id, fromRoom, toRoom string, cost float64, bidirectional bool) *BasicConnection

CreatePortalConnection creates a magical portal connection

func CreateSecretPassageConnection

func CreateSecretPassageConnection(id, fromRoom, toRoom string, cost float64, requirements []string) *BasicConnection

CreateSecretPassageConnection creates a hidden passage that may have requirements

func CreateStairsConnection

func CreateStairsConnection(id, fromRoom, toRoom string, cost float64, goingUp bool) *BasicConnection

CreateStairsConnection creates a stairway connection between floors

func CreateTunnelConnection

func CreateTunnelConnection(id, fromRoom, toRoom string, cost float64) *BasicConnection

CreateTunnelConnection creates an underground tunnel

func NewBasicConnection

func NewBasicConnection(config BasicConnectionConfig) *BasicConnection

NewBasicConnection creates a new basic connection

func (*BasicConnection) AddRequirement

func (bc *BasicConnection) AddRequirement(requirement string)

AddRequirement adds a new requirement

func (*BasicConnection) GetConnectionType

func (bc *BasicConnection) GetConnectionType() ConnectionType

GetConnectionType returns the connection type

func (*BasicConnection) GetFromRoom

func (bc *BasicConnection) GetFromRoom() string

GetFromRoom returns the source room ID

func (*BasicConnection) GetID

func (bc *BasicConnection) GetID() string

GetID returns the connection ID

func (*BasicConnection) GetRequirements

func (bc *BasicConnection) GetRequirements() []string

GetRequirements returns any requirements for using this connection

func (*BasicConnection) GetToRoom

func (bc *BasicConnection) GetToRoom() string

GetToRoom returns the destination room ID

func (*BasicConnection) GetTraversalCost

func (bc *BasicConnection) GetTraversalCost(_ core.Entity) float64

GetTraversalCost returns the cost to traverse this connection

func (*BasicConnection) GetType

func (bc *BasicConnection) GetType() core.EntityType

GetType returns the entity type (implementing core.Entity)

func (*BasicConnection) HasRequirement

func (bc *BasicConnection) HasRequirement(requirement string) bool

HasRequirement checks if a specific requirement exists

func (*BasicConnection) IsPassable

func (bc *BasicConnection) IsPassable(_ core.Entity) bool

IsPassable checks if entities can currently traverse this connection

func (*BasicConnection) IsReversible

func (bc *BasicConnection) IsReversible() bool

IsReversible returns true if the connection works both ways

func (*BasicConnection) RemoveRequirement

func (bc *BasicConnection) RemoveRequirement(requirement string)

RemoveRequirement removes a requirement

func (*BasicConnection) SetPassable

func (bc *BasicConnection) SetPassable(passable bool)

SetPassable changes the passable state of the connection

type BasicConnectionConfig

type BasicConnectionConfig struct {
	ID           string
	Type         string
	ConnType     ConnectionType
	FromRoom     string
	ToRoom       string
	Reversible   bool
	Passable     bool
	Cost         float64
	Requirements []string
}

BasicConnectionConfig holds configuration for creating a basic connection

type BasicRoom

type BasicRoom struct {
	// contains filtered or unexported fields
}

BasicRoom implements the Room interface with event integration

func LoadRoomFromContext

func LoadRoomFromContext(_ context.Context, gameCtx game.Context[RoomData]) (*BasicRoom, error)

LoadRoomFromContext creates a BasicRoom from data using the GameContext pattern. This allows the room to integrate with the event system and other game infrastructure.

func NewBasicRoom

func NewBasicRoom(config BasicRoomConfig) *BasicRoom

NewBasicRoom creates a new basic room (call ConnectToEventBus after creation)

func (*BasicRoom) CanPlaceEntity

func (r *BasicRoom) CanPlaceEntity(entity core.Entity, pos Position) bool

CanPlaceEntity checks if an entity can be placed at a position

func (*BasicRoom) ConnectToEventBus added in v0.1.1

func (r *BasicRoom) ConnectToEventBus(bus events.EventBus)

ConnectToEventBus connects the room to an event bus for typed event publishing

func (*BasicRoom) GetAllEntities

func (r *BasicRoom) GetAllEntities() map[string]core.Entity

GetAllEntities returns all entities in the room

func (*BasicRoom) GetBoundary added in v0.6.0

func (r *BasicRoom) GetBoundary(from, to Position) (Boundary, bool)

GetBoundary returns the normalized boundary for an endpoint pair regardless of the direction passed by the caller.

func (*BasicRoom) GetEntitiesAt

func (r *BasicRoom) GetEntitiesAt(pos Position) []core.Entity

GetEntitiesAt returns all entities at a specific position

func (*BasicRoom) GetEntitiesInRange

func (r *BasicRoom) GetEntitiesInRange(center Position, radius float64) []core.Entity

GetEntitiesInRange returns entities within a given range

func (*BasicRoom) GetEntityCount

func (r *BasicRoom) GetEntityCount() int

GetEntityCount returns the number of entities in the room

func (*BasicRoom) GetEntityCubePosition added in v0.2.0

func (r *BasicRoom) GetEntityCubePosition(entityID string) *CubeCoordinate

GetEntityCubePosition returns the cube coordinate position of an entity Returns nil if the entity doesn't exist or the grid is not a hex grid

func (*BasicRoom) GetEntityPosition

func (r *BasicRoom) GetEntityPosition(entityID string) (Position, bool)

GetEntityPosition returns the position of an entity

func (*BasicRoom) GetGrid

func (r *BasicRoom) GetGrid() Grid

GetGrid returns the grid system used by this room

func (*BasicRoom) GetID

func (r *BasicRoom) GetID() string

GetID returns the room's unique identifier (implements core.Entity)

func (*BasicRoom) GetLineOfSight

func (r *BasicRoom) GetLineOfSight(from, to Position) []Position

GetLineOfSight returns positions along the line of sight

func (*BasicRoom) GetOccupiedPositions

func (r *BasicRoom) GetOccupiedPositions() []Position

GetOccupiedPositions returns all positions that have entities

func (*BasicRoom) GetPositionsInRange

func (r *BasicRoom) GetPositionsInRange(center Position, radius float64) []Position

GetPositionsInRange returns all positions within a given range

func (*BasicRoom) GetType

func (r *BasicRoom) GetType() core.EntityType

GetType returns the room's type (implements core.Entity)

func (*BasicRoom) IsBoundaryLineOfSightBlocked added in v0.6.0

func (r *BasicRoom) IsBoundaryLineOfSightBlocked(from, to Position) bool

IsBoundaryLineOfSightBlocked reports whether a registered boundary blocks line of sight across the crossing between two positions.

func (*BasicRoom) IsBoundaryMovementBlocked added in v0.6.0

func (r *BasicRoom) IsBoundaryMovementBlocked(from, to Position) bool

IsBoundaryMovementBlocked reports whether a registered boundary blocks the crossing between two positions.

func (*BasicRoom) IsLineOfSightBlocked

func (r *BasicRoom) IsLineOfSightBlocked(from, to Position) bool

IsLineOfSightBlocked reports whether sight between two cells is blocked.

SIGHT IS NOT ONE LINE. A single centre-to-centre ray was the rule here until rpg-toolkit#1022, and it was wrong in two ways that turned out to be one: squares disagreed with themselves by direction (Bresenham steps X first one way and Y first the other, so A→B and B→A are different cells), and every grid family blocked far more than the game's own rule allows. Measured against 5e's stated test — you can see a target if a line from ANY corner of your space to ANY corner of theirs is unobstructed — the old rule blocked 3.6x too many pairs on squares and 4.5x too many on hexes, and hid about one in seven things a player should have been able to see. It never blocked too little; it was uniformly stricter than the game.

So sight asks for a LANE rather than a line: blocked only when the direct lane is obstructed AND so is every lane from a neighbouring cell that does not give ground. Corner-clipping stops costing you the whole sightline, which is what a player at the table already assumes.

This reaches the corner rule exactly on squares and within 0.01% of it on hexes. The remaining gap is the price of staying grid-native rather than evaluating every pair of cell-polygon corners in the plane.

SYMMETRY IS STRUCTURAL, not incidental: every lane is rasterized on the canonical ray, and the neighbour lanes are explored from both ends, so the rule has no direction left to disagree about. It is pinned as a law over fuzzed rooms in every grid family.

Boundaries are hard obstructions and remain absolute. Entities are soft obstructions that eligible neighbour lanes can bypass. Gridless rooms keep a single lane because their positions have no cell extent to lean around.

The common case costs exactly what it used to. A pair whose direct lane is clear returns on that first test, and most pairs are clear — the extra work lands only where the answer used to be wrong. That matters because callers run this O(range²) per viewer.

func (*BasicRoom) IsPositionOccupied

func (r *BasicRoom) IsPositionOccupied(pos Position) bool

IsPositionOccupied checks if a position is occupied

func (*BasicRoom) MoveEntity

func (r *BasicRoom) MoveEntity(entityID string, newPos Position) error

MoveEntity moves an entity to a new position

func (*BasicRoom) PlaceEntity

func (r *BasicRoom) PlaceEntity(entity core.Entity, pos Position) error

PlaceEntity places an entity at a specific position

func (*BasicRoom) RegisterBoundary added in v0.6.0

func (r *BasicRoom) RegisterBoundary(boundary Boundary) error

RegisterBoundary validates and registers an undirected boundary between two adjacent in-grid positions. Endpoint order is normalized and registering the same pair replaces its blocking flags.

func (*BasicRoom) RemoveBoundary added in v0.6.0

func (r *BasicRoom) RemoveBoundary(from, to Position) error

RemoveBoundary validates an undirected boundary pair and removes it. Removing an otherwise-valid pair with no registered boundary is a no-op.

func (*BasicRoom) RemoveEntity

func (r *BasicRoom) RemoveEntity(entityID string) error

RemoveEntity removes an entity from the room

func (*BasicRoom) ToData

func (r *BasicRoom) ToData() RoomData

ToData converts a BasicRoom to RoomData for persistence. This captures the room's state including all placed entities.

type BasicRoomConfig

type BasicRoomConfig struct {
	ID   string
	Type string
	Grid Grid
}

BasicRoomConfig holds configuration for creating a basic room

type BasicRoomOrchestrator

type BasicRoomOrchestrator struct {
	// contains filtered or unexported fields
}

BasicRoomOrchestrator implements the RoomOrchestrator interface

func NewBasicRoomOrchestrator

func NewBasicRoomOrchestrator(config BasicRoomOrchestratorConfig) *BasicRoomOrchestrator

NewBasicRoomOrchestrator creates a new basic room orchestrator

func (*BasicRoomOrchestrator) AddConnection

func (bro *BasicRoomOrchestrator) AddConnection(connection Connection) error

AddConnection creates a connection between two rooms.

func (*BasicRoomOrchestrator) AddRoom

func (bro *BasicRoomOrchestrator) AddRoom(room Room) error

AddRoom adds a room to the orchestrator. Entities already in the room are indexed synchronously. Adding a room that duplicates an entity already indexed in another managed room fails without changing the orchestrator.

func (*BasicRoomOrchestrator) CanMoveEntityBetweenRooms

func (bro *BasicRoomOrchestrator) CanMoveEntityBetweenRooms(
	entityIDStr, fromRoomStr, toRoomStr, connectionIDStr string,
) bool

CanMoveEntityBetweenRooms checks whether an indexed, physically present entity can depart through the named connection.

func (*BasicRoomOrchestrator) ConnectToEventBus added in v0.1.1

func (bro *BasicRoomOrchestrator) ConnectToEventBus(bus events.EventBus)

ConnectToEventBus connects all typed topics to the event bus

func (*BasicRoomOrchestrator) FindPath

func (bro *BasicRoomOrchestrator) FindPath(fromRoom, toRoom string, entity core.Entity) ([]string, error)

FindPath finds a path between rooms using connections (simple implementation)

func (*BasicRoomOrchestrator) GetAllConnections

func (bro *BasicRoomOrchestrator) GetAllConnections() map[string]Connection

GetAllConnections returns all connections

func (*BasicRoomOrchestrator) GetAllRooms

func (bro *BasicRoomOrchestrator) GetAllRooms() map[string]Room

GetAllRooms returns all managed rooms

func (*BasicRoomOrchestrator) GetConnection

func (bro *BasicRoomOrchestrator) GetConnection(connectionIDStr string) (Connection, bool)

GetConnection retrieves a connection by ID

func (*BasicRoomOrchestrator) GetEntityRoom

func (bro *BasicRoomOrchestrator) GetEntityRoom(entityIDStr string) (string, bool)

GetEntityRoom returns which room contains the entity

func (*BasicRoomOrchestrator) GetEventBus

func (bro *BasicRoomOrchestrator) GetEventBus() events.EventBus

GetEventBus returns the current event bus (implements EventBusIntegration)

func (*BasicRoomOrchestrator) GetID

func (bro *BasicRoomOrchestrator) GetID() string

GetID returns the orchestrator ID

func (*BasicRoomOrchestrator) GetLayout

func (bro *BasicRoomOrchestrator) GetLayout() LayoutType

GetLayout returns the current layout pattern.

func (*BasicRoomOrchestrator) GetRoom

func (bro *BasicRoomOrchestrator) GetRoom(roomIDStr string) (Room, bool)

GetRoom retrieves a room by ID

func (*BasicRoomOrchestrator) GetRoomConnections

func (bro *BasicRoomOrchestrator) GetRoomConnections(roomIDStr string) []Connection

GetRoomConnections returns all connections for a specific room

func (*BasicRoomOrchestrator) GetType

func (bro *BasicRoomOrchestrator) GetType() core.EntityType

GetType returns the entity type

func (*BasicRoomOrchestrator) MoveEntity added in v0.8.0

MoveEntity moves an indexed entity within its managed room and returns the completed movement. The index remains unchanged.

func (*BasicRoomOrchestrator) MoveEntityBetweenRooms

func (bro *BasicRoomOrchestrator) MoveEntityBetweenRooms(
	entityIDStr, fromRoomStr, toRoomStr, connectionIDStr string,
) error

MoveEntityBetweenRooms preserves the legacy signature for source compatibility. It performs a logical transition only: the entity is removed from the source and remains unplaced and unindexed until PlaceEntity chooses a destination position. New compositions should call TransitionEntity and consume its explicit output.

func (*BasicRoomOrchestrator) PlaceEntity added in v0.8.0

PlaceEntity places an entity in a managed room and synchronously indexes its membership. Room publication remains an observer tail and is not consumed by the orchestrator.

func (*BasicRoomOrchestrator) RemoveConnection

func (bro *BasicRoomOrchestrator) RemoveConnection(connectionIDStr string) error

RemoveConnection removes a connection.

func (*BasicRoomOrchestrator) RemoveEntity added in v0.8.0

RemoveEntity removes an indexed entity from its managed room, synchronously clears membership, and returns both the entity and spatial removal.

func (*BasicRoomOrchestrator) RemoveRoom

func (bro *BasicRoomOrchestrator) RemoveRoom(roomIDStr string) error

RemoveRoom removes a room from the orchestrator.

func (*BasicRoomOrchestrator) SetEventBus

func (bro *BasicRoomOrchestrator) SetEventBus(bus events.EventBus)

SetEventBus sets the event bus for the orchestrator (implements EventBusIntegration)

func (*BasicRoomOrchestrator) SetLayout

func (bro *BasicRoomOrchestrator) SetLayout(layout LayoutType) error

SetLayout configures the arrangement pattern.

func (*BasicRoomOrchestrator) TransitionEntity added in v0.8.0

func (bro *BasicRoomOrchestrator) TransitionEntity(
	in *TransitionEntityInput,
) (*TransitionEntityOutput, error)

TransitionEntity removes an entity from its source room through a passable connection and leaves it deliberately unplaced and unindexed. The returned entity and transition value let a composition choose a destination position and finish the operation through PlaceEntity.

type BasicRoomOrchestratorConfig

type BasicRoomOrchestratorConfig struct {
	ID     OrchestratorID // Optional: if empty, will auto-generate
	Type   string
	Layout LayoutType
}

BasicRoomOrchestratorConfig holds configuration for creating a basic room orchestrator

type Boundary added in v0.6.0

type Boundary struct {
	// From is one endpoint of the crossing. Registered boundaries expose the
	// lexicographically first endpoint here.
	From Position

	// To is the other endpoint of the crossing. Registered boundaries expose
	// the lexicographically second endpoint here.
	To Position

	// BlocksMovement reports whether an entity may cross this boundary.
	BlocksMovement bool

	// BlocksLineOfSight reports whether line of sight may cross this boundary.
	BlocksLineOfSight bool
}

Boundary represents an undirected crossing between two adjacent grid positions. BasicRoom normalizes From and To when the boundary is registered, so the same boundary is found regardless of the caller's direction.

Boundaries are runtime spatial state only. Higher-level domains own any authored or persisted representation and register their current crossings when reconstructing a room.

type BoundaryAwareRoom added in v0.6.0

type BoundaryAwareRoom interface {
	Room

	// RegisterBoundary validates and normalizes an undirected adjacent in-grid
	// boundary. Registering an existing pair updates its blocking flags.
	RegisterBoundary(boundary Boundary) error

	// RemoveBoundary validates an undirected adjacent in-grid pair and removes
	// its boundary. Removing an unregistered valid pair is a no-op.
	RemoveBoundary(from, to Position) error

	// GetBoundary returns the normalized boundary registered for either order
	// of the endpoint pair.
	GetBoundary(from, to Position) (Boundary, bool)

	// IsBoundaryMovementBlocked reports whether the endpoint crossing blocks
	// movement. It returns false when no boundary is registered.
	IsBoundaryMovementBlocked(from, to Position) bool

	// IsBoundaryLineOfSightBlocked reports whether the endpoint crossing blocks
	// line of sight. It returns false when no boundary is registered.
	IsBoundaryLineOfSightBlocked(from, to Position) bool
}

BoundaryAwareRoom is an optional Room capability for registering and querying barriers between adjacent grid positions. It is separate from Room so legacy Room implementations and callers remain compatible.

type Box added in v0.13.0

type Box struct {
	W float64 `json:"w"`
	D float64 `json:"d"`
}

Box is a rectangular footprint: W across the bearing it is drawn at, D along it. Both are in the caller's unit, the same one the embedding's cell width was measured in.

type Circle

type Circle struct {
	Center Position `json:"center"`
	Radius float64  `json:"radius"`
}

Circle represents a circular area

func (Circle) Contains

func (c Circle) Contains(pos Position) bool

Contains checks if a position is within the circle

func (Circle) Intersects

func (c Circle) Intersects(other Circle) bool

Intersects checks if this circle intersects with another circle

func (Circle) String

func (c Circle) String() string

String returns a string representation of the circle

type Connection

type Connection interface {
	core.Entity

	// GetConnectionType returns the connection type
	GetConnectionType() ConnectionType

	// GetFromRoom returns the source room ID
	GetFromRoom() string

	// GetToRoom returns the destination room ID
	GetToRoom() string

	// IsPassable checks if entities can currently traverse this connection
	IsPassable(entity core.Entity) bool

	// GetTraversalCost returns the cost to traverse this connection
	GetTraversalCost(entity core.Entity) float64

	// IsReversible returns true if the connection works both ways
	IsReversible() bool

	// GetRequirements returns any requirements for using this connection
	GetRequirements() []string
}

Connection represents a logical link between two rooms (ADR-0015: Abstract Connections)

type ConnectionAddedEvent added in v0.1.1

type ConnectionAddedEvent struct {
	OrchestratorID string    `json:"orchestrator_id"`
	ConnectionID   string    `json:"connection_id"`
	FromRoom       string    `json:"from_room"`
	ToRoom         string    `json:"to_room"`
	ConnectionType string    `json:"connection_type"`
	AddedAt        time.Time `json:"added_at"`
}

ConnectionAddedEvent contains data for connection addition events

type ConnectionID

type ConnectionID string

ConnectionID is a unique identifier for a connection

func NewConnectionID

func NewConnectionID() ConnectionID

NewConnectionID generates a new unique connection identifier

func (ConnectionID) String

func (id ConnectionID) String() string

type ConnectionRemovedEvent added in v0.1.1

type ConnectionRemovedEvent struct {
	OrchestratorID string    `json:"orchestrator_id"`
	ConnectionID   string    `json:"connection_id"`
	Reason         string    `json:"reason,omitempty"`
	RemovedAt      time.Time `json:"removed_at"`
}

ConnectionRemovedEvent contains data for connection removal events

type ConnectionType

type ConnectionType string

ConnectionType represents different types of connections between rooms

const (
	ConnectionTypeDoor    ConnectionType = "door"    // Standard doorway connection
	ConnectionTypeStairs  ConnectionType = "stairs"  // Stairway between different levels
	ConnectionTypePassage ConnectionType = "passage" // Corridor or hallway connection
	ConnectionTypePortal  ConnectionType = "portal"  // Magical or teleportation connection
	ConnectionTypeBridge  ConnectionType = "bridge"  // Bridge spanning a gap or obstacle
	ConnectionTypeTunnel  ConnectionType = "tunnel"  // Underground or enclosed tunnel
)

Connection type constants define the various ways rooms can be linked

type CoverageInput added in v0.13.0

type CoverageInput struct {
	Footprint Footprint
	At        Position
	Facing    float64
	Anchor    AnchorRule
}

CoverageInput places a footprint on the plane: which cell it is anchored at, which way it faces, and whether it sits on that cell or in front of it.

Facing is in degrees from east in the embedding's numeric plane, as HexEmbedding.Bearing reports it; positive 90 points south. Any finite angle is legal: nothing here snaps to a grid axis.

type CoverageOutput added in v0.13.0

type CoverageOutput struct {
	Cells map[Position]float64
}

CoverageOutput is which cells the footprint lies on, and how much of each.

Cells holds the fraction of each cell's area under the footprint, in (0, 1]. A cell the footprint misses is absent rather than zero. Edges — the cell boundaries the footprint's outline crosses, which is how a thin thing that covers under half of everything still blocks something — arrive with the first thin prop that needs them.

func Coverage added in v0.13.0

func Coverage(emb HexEmbedding, g Grid, in CoverageInput) (CoverageOutput, error)

Coverage rasterises a footprint at a transform onto a grid, returning the fraction of each cell's area under it.

Fractions come out; thresholds go in above. What a covered cell means — caught by the blast, blocked, difficult — is the game's, and a rule that counts a cell at half would be a different number in a different rulebook without this changing. Only cells the grid considers valid are reported.

Returns ErrNoFootprint when no shape was given, ErrBadFootprint when its sides are not positive and finite, ErrBadCellWidth when the embedding has no frame, and ErrBadFootprintPlacement for an invalid anchor or facing.

func PlacedCoverage added in v0.14.0

func PlacedCoverage(in PlacedCoverageInput) (CoverageOutput, error)

PlacedCoverage returns area fractions for a freely placed footprint over Cells. Misses are absent. Inputs are not retained; errors return no partial map.

Example
package main

import (
	"fmt"

	"github.com/KirkDiggler/rpg-toolkit/tools/spatial"
)

func main() {
	emb := spatial.NewHexEmbedding(spatial.HexEmbeddingConfig{CellWidth: 5})
	placement := spatial.FootprintPlacement{
		Footprint: spatial.Footprint{Box: &spatial.Box{W: 4, D: 10}},
		Origin:    spatial.Point{X: 3.25, Y: -1.75}, Facing: 37,
	}
	coverage, err := spatial.PlacedCoverage(spatial.PlacedCoverageInput{
		Embedding: emb, Placement: placement,
		Cells: []spatial.Position{{}, {X: 1}, {Y: -1}},
	})
	if err != nil {
		fmt.Println(err)
		return
	}
	trace, err := spatial.TraceFootprint(spatial.FootprintTraceInput{
		Placement: placement,
		From:      spatial.Point{X: -5}, To: spatial.Point{X: 8},
	})
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(coverage.Cells, trace.Contact, trace.Interior)
}

type CubeCoordinate

type CubeCoordinate struct {
	X int `json:"x"`
	Y int `json:"y"`
	Z int `json:"z"`
}

CubeCoordinate represents a position in cube coordinate system (for hex grids) In hex grids, cube coordinates simplify distance and neighbor calculations

func AxialToCube added in v0.11.0

func AxialToCube(pos Position) CubeCoordinate

AxialToCube converts an axial Position (X=Q, Y=R) to a CubeCoordinate. It is the inverse of CubeCoordinate.ToAxial and, with it, the ONE definition of the axial basis this package speaks.

func OffsetCoordinateToCube

func OffsetCoordinateToCube(pos Position) CubeCoordinate

OffsetCoordinateToCube converts offset coordinate to cube coordinate Uses pointy-top orientation by default. Use OffsetCoordinateToCubeWithOrientation for flat-top.

func OffsetCoordinateToCubeWithOrientation added in v0.2.0

func OffsetCoordinateToCubeWithOrientation(pos Position, orientation HexOrientation) CubeCoordinate

OffsetCoordinateToCubeWithOrientation converts offset coordinate to cube coordinate using the specified hex orientation

func (CubeCoordinate) Add

Add adds another cube coordinate to this one

func (CubeCoordinate) Distance

func (c CubeCoordinate) Distance(other CubeCoordinate) int

Distance calculates the hex distance between two cube coordinates This is specific to hex grids and uses cube coordinate math

func (CubeCoordinate) Equals

func (c CubeCoordinate) Equals(other CubeCoordinate) bool

Equals checks if two cube coordinates are equal

func (CubeCoordinate) GetNeighbors

func (c CubeCoordinate) GetNeighbors() []CubeCoordinate

GetNeighbors returns all 6 neighboring cube coordinates

func (CubeCoordinate) IsValid

func (c CubeCoordinate) IsValid() bool

IsValid checks whether the cube coordinate is valid (x + y + z == 0). It compares exact unsigned magnitudes by sign instead of adding native ints, so valid and invalid coordinates remain distinguishable at int extremes.

func (CubeCoordinate) Scale

func (c CubeCoordinate) Scale(factor int) CubeCoordinate

Scale scales the cube coordinate by a factor

func (CubeCoordinate) String

func (c CubeCoordinate) String() string

String returns a string representation of the cube coordinate

func (CubeCoordinate) Subtract

func (c CubeCoordinate) Subtract(other CubeCoordinate) CubeCoordinate

Subtract subtracts another cube coordinate from this one

func (CubeCoordinate) ToAxial added in v0.11.0

func (c CubeCoordinate) ToAxial() Position

ToAxial reads a cube coordinate as the axial Position (X=Q, Y=R) this package hands out for hex cells: Q is cube X and R is cube Z.

WHICH TWO AXES is not a free choice, even though the lattice cannot tell (rpg-toolkit#1150). Any pair gives identical distance, neighbours and sight; what differs is the PICTURE. "Pointy-top" means the R axis runs straight across the screen, and the standard formula — x = √3·(q + r/2), y = 1.5·r — assumes R is cube Z. Reading Y as R instead drew the reference tomb as a diagonal band and survived every round-trip test in three modules. This matches OffsetCoordinateToCubeWithOrientation, which already puts the authored row in Z, and is pinned by hex_axial_basis_test.go against the pixel formula rather than against another conversion.

func (CubeCoordinate) ToOffsetCoordinate

func (c CubeCoordinate) ToOffsetCoordinate() Position

ToOffsetCoordinate converts cube coordinate to offset coordinate (for display) Uses pointy-top orientation by default. Use ToOffsetCoordinateWithOrientation for flat-top.

func (CubeCoordinate) ToOffsetCoordinateWithOrientation added in v0.2.0

func (c CubeCoordinate) ToOffsetCoordinateWithOrientation(orientation HexOrientation) Position

ToOffsetCoordinateWithOrientation converts cube coordinate to offset coordinate using the specified hex orientation

type Dimensions

type Dimensions struct {
	Width  float64 `json:"width"`
	Height float64 `json:"height"`
}

Dimensions represents the size of a spatial area

func (Dimensions) Area

func (d Dimensions) Area() float64

Area calculates the area of the dimensions

func (Dimensions) Contains

func (d Dimensions) Contains(pos Position) bool

Contains checks if a position is within the dimensions (assuming origin at 0,0)

func (Dimensions) String

func (d Dimensions) String() string

String returns a string representation of the dimensions

type EntityCubePlacement added in v0.3.0

type EntityCubePlacement struct {
	// EntityID is the unique identifier of the entity
	EntityID string `json:"entity_id"`

	// EntityType is the caller-defined type of the entity (e.g., "actor", "obstacle", "marker")
	EntityType string `json:"entity_type"`

	// CubePosition is where the entity is placed in the room (cube coordinates)
	CubePosition CubeCoordinate `json:"cube_position"`

	// Size is how many grid spaces the entity occupies (default 1)
	Size int `json:"size,omitempty"`

	// BlocksMovement indicates if this entity blocks movement through its space
	BlocksMovement bool `json:"blocks_movement"`

	// BlocksLineOfSight indicates if this entity blocks line of sight
	BlocksLineOfSight bool `json:"blocks_line_of_sight"`
}

EntityCubePlacement represents an entity's position using cube coordinates. Used for hex grids where cube coordinates (x, y, z where x+y+z=0) are the native format.

type EntityFilter

type EntityFilter interface {
	// Matches returns true if the entity matches the filter
	Matches(entity core.Entity) bool
}

EntityFilter defines filtering criteria for spatial queries

func CreateExcludeFilter

func CreateExcludeFilter(excludeIDs ...string) EntityFilter

CreateExcludeFilter creates a filter that excludes specific entity IDs

func CreateIncludeFilter

func CreateIncludeFilter(includeIDs ...string) EntityFilter

CreateIncludeFilter creates a filter that includes only specific entity IDs

type EntityMovedEvent added in v0.1.1

type EntityMovedEvent struct {
	EntityID         string          `json:"entity_id"`
	FromPosition     Position        `json:"from_position"`
	ToPosition       Position        `json:"to_position"`
	FromCubePosition *CubeCoordinate `json:"from_cube_position,omitempty"` // Only set for hex grids
	ToCubePosition   *CubeCoordinate `json:"to_cube_position,omitempty"`   // Only set for hex grids
	RoomID           string          `json:"room_id"`
	MovementType     string          `json:"movement_type"` // "normal", "teleport", "forced"
}

EntityMovedEvent contains data for entity movement events

type EntityMovementDelta added in v0.8.0

type EntityMovementDelta struct {
	EntityID core.EntityID
	RoomID   RoomID
	From     Position
	To       Position
}

EntityMovementDelta describes a completed move within one managed room.

type EntityPlacedEvent added in v0.1.1

type EntityPlacedEvent struct {
	EntityID     string          `json:"entity_id"`
	Position     Position        `json:"position"`
	CubePosition *CubeCoordinate `json:"cube_position,omitempty"` // Only set for hex grids
	RoomID       string          `json:"room_id"`
	GridType     string          `json:"grid_type"` // "square", "hex", "gridless"
}

EntityPlacedEvent contains data for entity placement events

type EntityPlacement

type EntityPlacement struct {
	// EntityID is the unique identifier of the entity
	EntityID string `json:"entity_id"`

	// EntityType is the caller-defined type of the entity (e.g., "actor", "obstacle", "marker")
	EntityType string `json:"entity_type"`

	// Position is where the entity is placed in the room (offset coordinates)
	Position Position `json:"position"`

	// Size is how many grid spaces the entity occupies (default 1)
	Size int `json:"size,omitempty"`

	// BlocksMovement indicates if this entity blocks movement through its space
	BlocksMovement bool `json:"blocks_movement"`

	// BlocksLineOfSight indicates if this entity blocks line of sight
	BlocksLineOfSight bool `json:"blocks_line_of_sight"`
}

EntityPlacement represents an entity's position and spatial properties in a room. Used for square and gridless grids that use offset coordinates.

type EntityPlacementDelta added in v0.8.0

type EntityPlacementDelta struct {
	EntityID core.EntityID
	RoomID   RoomID
	Position Position
}

EntityPlacementDelta describes a completed placement in a managed room.

type EntityRemovalDelta added in v0.8.0

type EntityRemovalDelta struct {
	EntityID core.EntityID
	RoomID   RoomID
	Position Position
}

EntityRemovalDelta describes a completed removal from a managed room.

type EntityRemovedEvent added in v0.1.1

type EntityRemovedEvent struct {
	EntityID    string   `json:"entity_id"`
	Position    Position `json:"position"`
	RoomID      string   `json:"room_id"`
	RemovalType string   `json:"removal_type"` // "normal", "destroyed", "teleported"
}

EntityRemovedEvent contains data for entity removal events

type EntityRoomTransitionEvent added in v0.1.1

type EntityRoomTransitionEvent struct {
	EntityID  string    `json:"entity_id"`
	FromRoom  string    `json:"from_room"`
	ToRoom    string    `json:"to_room"`
	Reason    string    `json:"reason,omitempty"`
	Timestamp time.Time `json:"timestamp"`
}

EntityRoomTransitionEvent contains data for entity room transition events

type EntityTransitionDelta added in v0.8.0

type EntityTransitionDelta struct {
	EntityID          core.EntityID
	FromRoom          RoomID
	ToRoom            RoomID
	ConnectionID      ConnectionID
	PlacementRequired bool
}

EntityTransitionDelta describes a logical room transition after departure. PlacementRequired is true because TransitionEntity does not choose a destination position and therefore does not claim destination membership.

type EventBusIntegration

type EventBusIntegration interface {
	// SetEventBus sets the event bus for the spatial module
	SetEventBus(bus events.EventBus)

	// GetEventBus returns the current event bus
	GetEventBus() events.EventBus
}

EventBusIntegration defines how the spatial module integrates with the event bus

type FieldInput added in v0.12.0

type FieldInput struct {
	Sources  []Position
	Passable func(from, to Position) bool
	Cost     func(from, to Position) int
	Limit    int
}

FieldInput asks for a distance field: every cell reachable from Sources under Passable, with its cost-weighted distance.

Sources is plural on purpose — a field flooded from several threats at once is a different question than a field flooded from one, and both are reads of the same primitive.

Passable is required: a field with no predicate would silently treat every cell as open, which is a zero value that lies. Cost nil means one per step, which makes the flood a breadth-first search. Limit 0 means unbounded.

type FieldOutput added in v0.12.0

type FieldOutput struct {
	Dist map[Position]int
	Prev map[Position]Position
}

FieldOutput is the field. Dist holds every reached cell keyed by position, including the sources at distance 0. Prev holds the cell each reached cell was entered from, and is absent for sources.

func Field added in v0.12.0

func Field(g Grid, in FieldInput) (FieldOutput, error)

Field floods outward from Sources over g.GetNeighbors, Dijkstra by Cost, and returns the distance and predecessor of every cell it reached.

It answers geometry and search over geometry only. What a cell means — blocked, costly, burning — is the game's, and reaches the field through Passable and Cost. Reach is a field with a Limit, a path is PathTo on the same field, and a blast that spreads around corners is a field under a walls-only predicate.

Neighbors are relaxed in X-then-Y order so equal-cost ties break the same way on every run and on every grid.

func (FieldOutput) PathTo added in v0.12.0

func (f FieldOutput) PathTo(goal Position) ([]Position, bool)

PathTo reads the field backwards from goal to the source that reached it. The returned path excludes the source and ends at goal, matching the contract a route has always had. ok is false when goal was never reached.

type Footprint added in v0.13.0

type Footprint struct {
	Box *Box `json:"box,omitempty"`
}

Footprint is a shape in the plane. Today a box is the only one; a polygon joins it when something in the game is shaped like one.

type FootprintPlacement added in v0.14.0

type FootprintPlacement struct {
	Footprint   Footprint
	Origin      Point
	Facing      float64
	LocalOffset Point
}

FootprintPlacement positions a footprint in the caller's continuous plane. LocalOffset is in the footprint's own along/across axes, before Facing.

type FootprintTraceInput added in v0.14.0

type FootprintTraceInput struct {
	Placement FootprintPlacement
	From      Point
	To        Point
}

FootprintTraceInput describes a closed planar segment and one placed footprint.

type FootprintTraceOutput added in v0.14.0

type FootprintTraceOutput struct {
	Contact  bool
	Interior bool
	Enter    float64
	Leave    float64
}

FootprintTraceOutput distinguishes closed contact from positive-length interior. Enter and Leave parameterize the contact interval; a miss is the zero value.

func TraceFootprint added in v0.14.0

func TraceFootprint(in FootprintTraceInput) (FootprintTraceOutput, error)

TraceFootprint reports contact with the full rectangle, independent of any grid. It does not decide whether contact blocks movement, sight, or an attack.

type Grid

type Grid interface {
	// GetShape returns the grid type
	GetShape() GridShape

	// IsValidPosition checks if a position is valid within the grid
	IsValidPosition(pos Position) bool

	// GetDimensions returns the grid dimensions
	GetDimensions() Dimensions

	// Distance calculates the distance between two positions
	Distance(from, to Position) float64

	// GetNeighbors returns all adjacent positions
	GetNeighbors(pos Position) []Position

	// IsAdjacent checks if two positions are adjacent
	IsAdjacent(pos1, pos2 Position) bool

	// GetLineOfSight returns positions along the line of sight
	GetLineOfSight(from, to Position) []Position

	// GetPositionsInRange returns all positions within a given range
	GetPositionsInRange(center Position, radius float64) []Position
}

Grid defines the interface for all grid systems

type GridShape

type GridShape int

GridShape represents the type of grid system

const (
	// GridShapeSquare represents a square grid system using Chebyshev distance
	GridShapeSquare GridShape = iota
	// GridShapeHex represents a hexagonal grid system using cube coordinates
	GridShapeHex
	// GridShapeGridless represents a continuous gridless system
	GridShapeGridless
)

type GridlessConfig

type GridlessConfig struct {
	Width  float64
	Height float64
}

GridlessConfig holds configuration for creating a gridless room

type GridlessRoom

type GridlessRoom struct {
	// contains filtered or unexported fields
}

GridlessRoom implements a continuous gridless spatial system Uses Euclidean distance and allows approximate positioning

func NewGridlessRoom

func NewGridlessRoom(config GridlessConfig) *GridlessRoom

NewGridlessRoom creates a new gridless room with the given dimensions

func (*GridlessRoom) Distance

func (gr *GridlessRoom) Distance(from, to Position) float64

Distance calculates the Euclidean distance between two positions This is the true geometric distance, not constrained by grid

func (*GridlessRoom) GetDimensions

func (gr *GridlessRoom) GetDimensions() Dimensions

GetDimensions returns the room dimensions

func (*GridlessRoom) GetLineOfSight

func (gr *GridlessRoom) GetLineOfSight(from, to Position) []Position

GetLineOfSight returns positions along the line of sight Since there's no grid, we sample points along the line

func (*GridlessRoom) GetNearestPosition

func (gr *GridlessRoom) GetNearestPosition(pos Position) Position

GetNearestPosition returns the nearest valid position to the given position Useful for "snapping" entities to valid positions

func (*GridlessRoom) GetNeighbors

func (gr *GridlessRoom) GetNeighbors(pos Position) []Position

GetNeighbors returns positions in a circle around the given position Since there's no grid, we return positions at various angles

func (*GridlessRoom) GetPositionsInArc

func (gr *GridlessRoom) GetPositionsInArc(center Position, radius float64, startAngle, endAngle float64) []Position

GetPositionsInArc returns positions within an arc (portion of a circle) This is useful for gridless rooms with arc-shaped areas

func (*GridlessRoom) GetPositionsInCircle

func (gr *GridlessRoom) GetPositionsInCircle(circle Circle) []Position

GetPositionsInCircle returns positions within a circular area

func (*GridlessRoom) GetPositionsInCone

func (gr *GridlessRoom) GetPositionsInCone(
	origin Position, direction Position, length float64, angle float64,
) []Position

GetPositionsInCone returns positions within a cone shape

func (*GridlessRoom) GetPositionsInLine

func (gr *GridlessRoom) GetPositionsInLine(from, to Position) []Position

GetPositionsInLine returns positions along a line

func (*GridlessRoom) GetPositionsInRange

func (gr *GridlessRoom) GetPositionsInRange(center Position, radius float64) []Position

GetPositionsInRange returns all positions within range Since there's no grid, we sample in a circular pattern

func (*GridlessRoom) GetPositionsInRectangle

func (gr *GridlessRoom) GetPositionsInRectangle(rect Rectangle) []Position

GetPositionsInRectangle returns positions within a rectangular area

func (*GridlessRoom) GetShape

func (gr *GridlessRoom) GetShape() GridShape

GetShape returns the grid shape type

func (*GridlessRoom) IsAdjacent

func (gr *GridlessRoom) IsAdjacent(pos1, pos2 Position) bool

IsAdjacent checks if two positions are adjacent (within distance 1)

func (*GridlessRoom) IsValidPosition

func (gr *GridlessRoom) IsValidPosition(pos Position) bool

IsValidPosition checks if a position is within the room bounds In gridless rooms, any position within the dimensions is valid

type HexEmbedding added in v0.13.0

type HexEmbedding struct {
	// contains filtered or unexported fields
}

HexEmbedding is a hex grid's embedding in the plane: where a cell's centre sits, where its six corners are, and the bearing from one cell to another.

It is the frame every areal question is answered in. Distance and adjacency are answered in axial coordinates, which are orientation-free and unitless; coverage, angles and areas are not, and this is what they need.

The zero value has no frame and answers zero points and no bearings. Build one with NewHexEmbedding, and check the config with HexEmbeddingConfig.Validate first if the width came from outside.

func NewHexEmbedding added in v0.13.0

func NewHexEmbedding(c HexEmbeddingConfig) HexEmbedding

NewHexEmbedding builds the embedding for one orientation at one cell width.

X runs east and Y runs south, the screen's axes and the axes the authored grids already run on. A cell's circumradius — the distance from its centre to a corner — is CellWidth/sqrt(3) under both orientations, because across the flats is sqrt(3) circumradii either way.

A CellWidth that is not positive or finite yields an embedding with no frame, whose methods return zero points and report no bearing. That is deliberate: the alternative is a panic in the middle of a raster, or a silent frame of some invented size. Callers that take a width from content validate the config.

func (HexEmbedding) Bearing added in v0.13.0

func (e HexEmbedding) Bearing(from, to Position) (degrees float64, ok bool)

Bearing reports the direction from one cell's centre to another's, in degrees within [0, 360), measured from east in the numeric plane. Because Y runs south, positive 90 points south.

Reports false when the two cells are the same, and when the embedding has no frame: neither has a direction at all, and a caller that wants to say "toward yourself" words that refusal for itself.

func (HexEmbedding) CellCentre added in v0.13.0

func (e HexEmbedding) CellCentre(cell Position) Point

CellCentre is the point at the middle of a cell.

func (HexEmbedding) CellCorners added in v0.13.0

func (e HexEmbedding) CellCorners(cell Position) [6]Point

CellCorners is a cell's six corners in the plane, in boundary order. The polygon is convex and closed by its first point.

type HexEmbeddingConfig added in v0.13.0

type HexEmbeddingConfig struct {
	// Orientation is which way the hexes are turned. The same lengths hold
	// under both: a flat-top hex is a pointy-top hex rotated thirty degrees.
	Orientation HexOrientation

	// CellWidth is the across-the-flats width of one cell, in the caller's
	// unit. Must be positive.
	CellWidth float64
}

HexEmbeddingConfig describes one hex grid's place in the plane.

CellWidth is measured ACROSS THE FLATS — the distance between a hex's two parallel sides, which is also the distance between the centres of two neighbouring cells. It is in the caller's own unit: spatial holds no notion of feet, so a rulebook that calls a cell five feet passes 5 and reads everything back in feet.

func (HexEmbeddingConfig) Validate added in v0.13.0

func (c HexEmbeddingConfig) Validate() error

Validate reports whether the config describes a usable plane. Returns ErrBadCellWidth when CellWidth is not positive or finite.

type HexGrid

type HexGrid struct {
	// contains filtered or unexported fields
}

HexGrid implements a bounded hex grid whose Position values are non-negative offset column/row coordinates. Orientation selects pointy-top or flat-top offset conversion; cube coordinates are used internally for hex mathematics.

func NewHexGrid

func NewHexGrid(config HexGridConfig) *HexGrid

NewHexGrid creates a bounded offset-coordinate hex grid with the given dimensions. The zero-value orientation is pointy-top.

func (*HexGrid) CubeToOffset

func (hg *HexGrid) CubeToOffset(cube CubeCoordinate) Position

CubeToOffset converts a cube coordinate to offset coordinate using this grid's orientation

func (*HexGrid) Distance

func (hg *HexGrid) Distance(from, to Position) float64

Distance calculates the distance between two positions using hex grid rules Converts to cube coordinates and uses hex distance formula

func (*HexGrid) GetCubeNeighbors

func (hg *HexGrid) GetCubeNeighbors(pos Position) []CubeCoordinate

GetCubeNeighbors returns the 6 cube coordinate neighbors of a position

func (*HexGrid) GetDimensions

func (hg *HexGrid) GetDimensions() Dimensions

GetDimensions returns the grid dimensions

func (*HexGrid) GetHexRing

func (hg *HexGrid) GetHexRing(center Position, radius int) []Position

GetHexRing returns positions forming a ring at a specific distance from center This is a hex-specific function for ring-shaped areas

func (*HexGrid) GetHexSpiral

func (hg *HexGrid) GetHexSpiral(center Position, radius int) []Position

GetHexSpiral returns positions in a spiral pattern from center outward Useful for area effects that expand outward

func (*HexGrid) GetLineOfSight

func (hg *HexGrid) GetLineOfSight(from, to Position) []Position

GetLineOfSight returns positions along the line of sight between two positions Uses cube coordinate lerp for hex line drawing

func (*HexGrid) GetNeighbors

func (hg *HexGrid) GetNeighbors(pos Position) []Position

GetNeighbors returns all 6 adjacent positions in hex grid

func (*HexGrid) GetOrientation

func (hg *HexGrid) GetOrientation() HexOrientation

GetOrientation returns the hex grid orientation (pointy-top or flat-top)

func (*HexGrid) GetPositionsInCircle

func (hg *HexGrid) GetPositionsInCircle(circle Circle) []Position

GetPositionsInCircle returns all positions within a circular area using hex distance

func (*HexGrid) GetPositionsInCone

func (hg *HexGrid) GetPositionsInCone(origin Position, direction Position, length float64, angle float64) []Position

GetPositionsInCone returns positions within a cone shape This is more complex for hex grids due to the 6-sided nature

func (*HexGrid) GetPositionsInLine

func (hg *HexGrid) GetPositionsInLine(from, to Position) []Position

GetPositionsInLine returns positions along a line from start to end

func (*HexGrid) GetPositionsInRange

func (hg *HexGrid) GetPositionsInRange(center Position, radius float64) []Position

GetPositionsInRange returns all positions within a given range using hex distance

func (*HexGrid) GetPositionsInRectangle

func (hg *HexGrid) GetPositionsInRectangle(rect Rectangle) []Position

GetPositionsInRectangle returns all positions within a rectangular area Note: This is approximate for hex grids since rectangles don't align perfectly with hex geometry

func (*HexGrid) GetShape

func (hg *HexGrid) GetShape() GridShape

GetShape returns the grid shape type

func (*HexGrid) IsAdjacent

func (hg *HexGrid) IsAdjacent(pos1, pos2 Position) bool

IsAdjacent checks if two positions are adjacent (within 1 hex)

func (*HexGrid) IsPointyTop added in v0.2.0

func (hg *HexGrid) IsPointyTop() bool

IsPointyTop returns true if the grid uses pointy-top orientation

func (*HexGrid) IsValidPosition

func (hg *HexGrid) IsValidPosition(pos Position) bool

IsValidPosition checks if a position is valid within the grid bounds

func (*HexGrid) OffsetToCube

func (hg *HexGrid) OffsetToCube(pos Position) CubeCoordinate

OffsetToCube converts an offset coordinate to cube coordinate using this grid's orientation

type HexGridConfig

type HexGridConfig struct {
	Width       float64
	Height      float64
	PointyTop   bool           // Deprecated: use Orientation instead. true for pointy-top, false for flat-top
	Orientation HexOrientation // The hex orientation (pointy-top or flat-top)
}

HexGridConfig holds configuration for creating a hex grid

type HexOrientation added in v0.2.0

type HexOrientation int

HexOrientation represents the orientation of a hexagonal grid

const (
	// HexOrientationPointyTop is the default orientation where hexes have a pointed top
	// This is the zero-value hex grid orientation
	HexOrientationPointyTop HexOrientation = iota
	// HexOrientationFlatTop is an alternative orientation where hexes have a flat top
	HexOrientationFlatTop
)

func (HexOrientation) String added in v0.2.0

func (o HexOrientation) String() string

String returns the string representation of the hex orientation

type LayoutChangedEvent added in v0.1.1

type LayoutChangedEvent struct {
	OrchestratorID string    `json:"orchestrator_id"`
	OldLayout      string    `json:"old_layout,omitempty"`
	NewLayout      string    `json:"new_layout"`
	ChangedAt      time.Time `json:"changed_at"`
}

LayoutChangedEvent contains data for orchestrator layout change events

type LayoutType

type LayoutType string

LayoutType represents different arrangement patterns for multiple rooms

const (
	LayoutTypeTower     LayoutType = "tower"     // Vertical stacking arrangement
	LayoutTypeBranching LayoutType = "branching" // Hub and spoke arrangement
	LayoutTypeGrid      LayoutType = "grid"      // 2D grid arrangement
	LayoutTypeOrganic   LayoutType = "organic"   // Irregular organic connections
)

Layout type constants define how multiple rooms can be spatially arranged

type ManagedRoomMutator added in v0.8.0

type ManagedRoomMutator interface {
	PlaceEntity(in *PlaceEntityInput) (*PlaceEntityOutput, error)
	MoveEntity(in *MoveEntityInput) (*MoveEntityOutput, error)
	RemoveEntity(in *RemoveEntityInput) (*RemoveEntityOutput, error)
	TransitionEntity(in *TransitionEntityInput) (*TransitionEntityOutput, error)
}

ManagedRoomMutator is the supported mutation seam for entity membership in rooms owned by a BasicRoomOrchestrator. Hosts serialize mutating calls; read methods remain safe to call concurrently.

type MoveEntityInput added in v0.8.0

type MoveEntityInput struct {
	RoomID   RoomID
	EntityID core.EntityID
	To       Position
}

MoveEntityInput names a managed entity and its new in-room position.

type MoveEntityOutput added in v0.8.0

type MoveEntityOutput struct {
	Delta EntityMovementDelta
}

MoveEntityOutput returns the completed spatial movement as a value.

type OrchestratorID

type OrchestratorID string

OrchestratorID is a unique identifier for an orchestrator

func NewOrchestratorID

func NewOrchestratorID() OrchestratorID

NewOrchestratorID generates a new unique orchestrator identifier

func (OrchestratorID) String

func (id OrchestratorID) String() string

type PathFinder added in v0.4.0

type PathFinder interface {
	// FindPath returns a path from start to goal avoiding blocked hexes.
	// Returns the path excluding start, including goal.
	// Returns empty slice if no path exists or start == goal.
	FindPath(start, goal CubeCoordinate, blocked map[CubeCoordinate]bool) []CubeCoordinate
}

PathFinder finds paths between hex positions avoiding obstacles. Implementations can use different algorithms (A*, Dijkstra, weighted, etc.)

type PlaceEntityInput added in v0.8.0

type PlaceEntityInput struct {
	RoomID   RoomID
	Entity   core.Entity
	Position Position
}

PlaceEntityInput names an entity, managed room, and destination position.

type PlaceEntityOutput added in v0.8.0

type PlaceEntityOutput struct {
	Delta EntityPlacementDelta
}

PlaceEntityOutput returns the completed spatial placement as a value.

type Placeable

type Placeable interface {
	core.Entity

	// GetSize returns the size of the entity (for multi-space entities)
	GetSize() int

	// BlocksMovement returns true if the entity blocks movement
	BlocksMovement() bool

	// BlocksLineOfSight returns true if the entity blocks line of sight
	BlocksLineOfSight() bool
}

Placeable defines the interface for entities that can be placed spatially

type PlaceableData

type PlaceableData struct {
	// contains filtered or unexported fields
}

PlaceableData is a minimal implementation of Placeable for spatial queries. It contains just enough data to support movement and line of sight calculations.

func (*PlaceableData) BlocksLineOfSight

func (p *PlaceableData) BlocksLineOfSight() bool

BlocksLineOfSight returns true if the entity blocks line of sight

func (*PlaceableData) BlocksMovement

func (p *PlaceableData) BlocksMovement() bool

BlocksMovement returns true if the entity blocks movement

func (*PlaceableData) GetID

func (p *PlaceableData) GetID() string

GetID returns the entity's unique identifier

func (*PlaceableData) GetSize

func (p *PlaceableData) GetSize() int

GetSize returns the size of the entity

func (*PlaceableData) GetType

func (p *PlaceableData) GetType() core.EntityType

GetType returns the entity's type

type PlacedCoverageInput added in v0.14.0

type PlacedCoverageInput struct {
	Embedding HexEmbedding
	Placement FootprintPlacement
	Cells     []Position
}

PlacedCoverageInput declares the exact axial cells to inspect in an embedding.

type Point added in v0.13.0

type Point struct {
	X float64 `json:"x"`
	Y float64 `json:"y"`
}

Point is a location in the plane, in whatever unit the caller measured CellWidth in. Position addresses a cell; Point addresses the continuous space that cell occupies, and only geometry that needs an area or an angle ever leaves the one for the other.

type Position

type Position struct {
	X float64 `json:"x"`
	Y float64 `json:"y"`
}

Position represents a spatial position in 2D space NOTE: Distance calculations are grid-dependent and handled by Grid implementations

func CanonicalBoundaryRay added in v0.6.0

func CanonicalBoundaryRay(grid Grid, from, to Position) []Position

CanonicalBoundaryRay returns the one deterministic grid ray for an unordered endpoint pair, oriented from from toward to. It derives the ray once from lexicographically ordered endpoints, then reverses it when the caller requested the opposite direction. Boundary traversal therefore crosses identical physical edges in both directions while callers retain their requested path direction.

func (Position) Add

func (p Position) Add(other Position) Position

Add adds another position to this position

func (Position) Equals

func (p Position) Equals(other Position) bool

Equals checks if two positions are equal

func (Position) IsZero

func (p Position) IsZero() bool

IsZero checks if the position is at the origin

func (Position) Normalize

func (p Position) Normalize() Position

Normalize returns a normalized version of the position (for vector math)

func (Position) Scale

func (p Position) Scale(factor float64) Position

Scale scales the position by a factor

func (Position) String

func (p Position) String() string

String returns a string representation of the position

func (Position) Subtract

func (p Position) Subtract(other Position) Position

Subtract subtracts another position from this position

type Query

type Query interface {
	// GetType returns the query type
	GetType() string

	// GetRoom returns the room being queried
	GetRoom() Room

	// GetCenter returns the center position for the query
	GetCenter() Position

	// GetRadius returns the radius for range-based queries
	GetRadius() float64

	// GetFilter returns any entity filter for the query
	GetFilter() EntityFilter
}

Query represents a spatial query

type QueryEntitiesInRangeData

type QueryEntitiesInRangeData struct {
	Center  Position      `json:"center"`
	Radius  float64       `json:"radius"`
	RoomID  string        `json:"room_id"`
	Filter  EntityFilter  `json:"filter,omitempty"`
	Results []core.Entity `json:"results,omitempty"`
	Error   error         `json:"error,omitempty"`
}

QueryEntitiesInRangeData contains data for entity range queries

type QueryHandler

type QueryHandler interface {
	// ProcessQuery processes a spatial query and returns results
	ProcessQuery(query Query) (QueryResult, error)
}

QueryHandler defines the interface for spatial query processing

type QueryLineOfSightData

type QueryLineOfSightData struct {
	From    Position   `json:"from"`
	To      Position   `json:"to"`
	RoomID  string     `json:"room_id"`
	Results []Position `json:"results,omitempty"`
	Blocked bool       `json:"blocked,omitempty"`
	Error   error      `json:"error,omitempty"`
}

QueryLineOfSightData contains data for line of sight queries

type QueryMovementData

type QueryMovementData struct {
	Entity   core.Entity `json:"entity"`
	From     Position    `json:"from"`
	To       Position    `json:"to"`
	RoomID   string      `json:"room_id"`
	Valid    bool        `json:"valid,omitempty"`
	Path     []Position  `json:"path,omitempty"`
	Distance float64     `json:"distance,omitempty"`
	Error    error       `json:"error,omitempty"`
}

QueryMovementData contains data for movement queries

type QueryPlacementData

type QueryPlacementData struct {
	Entity   core.Entity `json:"entity"`
	Position Position    `json:"position"`
	RoomID   string      `json:"room_id"`
	Valid    bool        `json:"valid,omitempty"`
	Error    error       `json:"error,omitempty"`
}

QueryPlacementData contains data for placement queries

type QueryPositionsInRangeData

type QueryPositionsInRangeData struct {
	Center  Position   `json:"center"`
	Radius  float64    `json:"radius"`
	RoomID  string     `json:"room_id"`
	Results []Position `json:"results,omitempty"`
	Error   error      `json:"error,omitempty"`
}

QueryPositionsInRangeData contains data for position range queries

type QueryResult

type QueryResult interface {
	// GetPositions returns positions that match the query
	GetPositions() []Position

	// GetEntities returns entities that match the query
	GetEntities() []core.Entity

	// GetDistances returns distances for each result
	GetDistances() map[string]float64
}

QueryResult represents the result of a spatial query

type QueryUtils

type QueryUtils struct {
	// contains filtered or unexported fields
}

QueryUtils provides convenient methods for performing spatial queries

func NewQueryUtils

func NewQueryUtils(queryHandler *SpatialQueryHandler) *QueryUtils

NewQueryUtils creates a new query utilities instance

func (*QueryUtils) QueryEntitiesInRange

func (q *QueryUtils) QueryEntitiesInRange(
	ctx context.Context, center Position, radius float64, roomID string, filter EntityFilter,
) ([]core.Entity, error)

QueryEntitiesInRange performs an entities-in-range query directly through the query handler

func (*QueryUtils) QueryLineOfSight

func (q *QueryUtils) QueryLineOfSight(ctx context.Context, from, to Position, roomID string) ([]Position, bool, error)

QueryLineOfSight performs a line-of-sight query directly through the query handler

func (*QueryUtils) QueryMovement

func (q *QueryUtils) QueryMovement(
	ctx context.Context, entity core.Entity, from, to Position, roomID string,
) (bool, []Position, float64, error)

QueryMovement performs a movement query directly through the query handler

func (*QueryUtils) QueryPlacement

func (q *QueryUtils) QueryPlacement(
	ctx context.Context, entity core.Entity, position Position, roomID string,
) (bool, error)

QueryPlacement performs a placement query directly through the query handler

func (*QueryUtils) QueryPositionsInRange

func (q *QueryUtils) QueryPositionsInRange(
	ctx context.Context, center Position, radius float64, roomID string,
) ([]Position, error)

QueryPositionsInRange performs a positions-in-range query directly through the query handler

type Rectangle

type Rectangle struct {
	Position   Position   `json:"position"`
	Dimensions Dimensions `json:"dimensions"`
}

Rectangle represents a rectangular area

func (Rectangle) Center

func (r Rectangle) Center() Position

Center returns the center position of the rectangle

func (Rectangle) Contains

func (r Rectangle) Contains(pos Position) bool

Contains checks if a position is within the rectangle

func (Rectangle) Intersects

func (r Rectangle) Intersects(other Rectangle) bool

Intersects checks if this rectangle intersects with another rectangle

func (Rectangle) String

func (r Rectangle) String() string

String returns a string representation of the rectangle

type RemoveEntityInput added in v0.8.0

type RemoveEntityInput struct {
	RoomID   RoomID
	EntityID core.EntityID
}

RemoveEntityInput names an entity to remove from a managed room.

type RemoveEntityOutput added in v0.8.0

type RemoveEntityOutput struct {
	Entity core.Entity
	Delta  EntityRemovalDelta
}

RemoveEntityOutput returns the removed entity and completed spatial delta.

type Room

type Room interface {
	core.Entity

	// GetGrid returns the grid system used by this room
	GetGrid() Grid

	// PlaceEntity places an entity at a specific position
	PlaceEntity(entity core.Entity, pos Position) error

	// MoveEntity moves an entity to a new position
	MoveEntity(entityID string, newPos Position) error

	// RemoveEntity removes an entity from the room
	RemoveEntity(entityID string) error

	// GetEntitiesAt returns all entities at a specific position
	GetEntitiesAt(pos Position) []core.Entity

	// GetEntityPosition returns the position of an entity
	GetEntityPosition(entityID string) (Position, bool)

	// GetAllEntities returns all entities in the room
	GetAllEntities() map[string]core.Entity

	// GetEntitiesInRange returns entities within a given range
	GetEntitiesInRange(center Position, radius float64) []core.Entity

	// IsPositionOccupied checks if a position is occupied
	IsPositionOccupied(pos Position) bool

	// CanPlaceEntity checks if an entity can be placed at a position
	CanPlaceEntity(entity core.Entity, pos Position) bool

	// GetPositionsInRange returns all positions within a given range
	GetPositionsInRange(center Position, radius float64) []Position

	// GetLineOfSight returns positions along the line of sight
	GetLineOfSight(from, to Position) []Position

	// IsLineOfSightBlocked checks if every eligible sight lane is obstructed.
	IsLineOfSightBlocked(from, to Position) bool
}

Room defines the interface for spatial containers

type RoomAddedEvent added in v0.1.1

type RoomAddedEvent struct {
	OrchestratorID string    `json:"orchestrator_id"`
	RoomID         string    `json:"room_id"`
	RoomType       string    `json:"room_type,omitempty"`
	AddedAt        time.Time `json:"added_at"`
}

RoomAddedEvent contains data for room addition to orchestrator events

type RoomCreatedEvent added in v0.1.1

type RoomCreatedEvent struct {
	RoomID       string    `json:"room_id"`
	RoomType     string    `json:"room_type"`
	GridType     string    `json:"grid_type"`
	Width        int       `json:"width"`
	Height       int       `json:"height"`
	CreationTime time.Time `json:"creation_time"`
}

RoomCreatedEvent contains data for room creation events

type RoomData

type RoomData struct {
	// ID is the unique identifier for the room
	ID string `json:"id"`

	// Type categorizes the room (e.g., "dungeon", "tavern", "outdoor")
	Type string `json:"type"`

	// Width defines the horizontal size of the room
	Width int `json:"width"`

	// Height defines the vertical size of the room
	Height int `json:"height"`

	// GridType specifies the grid system: "square", "hex", or "gridless"
	GridType string `json:"grid_type"`

	// HexFlatTop specifies hex grid orientation
	// Only used when GridType is "hex"
	// false = pointy-top (default), true = flat-top
	HexFlatTop bool `json:"hex_flat_top,omitempty"`

	// Entities contains positioned entities within the room using offset coordinates.
	// Used for square and gridless grids.
	// Map of entity ID to their position and data.
	Entities map[string]EntityPlacement `json:"entities,omitempty"`

	// CubeEntities contains positioned entities within the room using cube coordinates.
	// Used for hex grids where cube coordinates are the native format.
	// Map of entity ID to their position and data.
	CubeEntities map[string]EntityCubePlacement `json:"cube_entities,omitempty"`
}

RoomData contains all information needed to persist and reconstruct a room. This follows the established data pattern for serialization and loading.

type RoomID

type RoomID string

RoomID is a unique identifier for a room

func NewRoomID

func NewRoomID() RoomID

NewRoomID generates a new unique room identifier

func (RoomID) String

func (id RoomID) String() string

String conversion methods

type RoomOrchestrator

type RoomOrchestrator interface {
	core.Entity
	EventBusIntegration

	// AddRoom adds a room to the orchestrator
	AddRoom(room Room) error

	// RemoveRoom removes a room from the orchestrator
	RemoveRoom(roomID string) error

	// GetRoom retrieves a room by ID
	GetRoom(roomID string) (Room, bool)

	// GetAllRooms returns all managed rooms
	GetAllRooms() map[string]Room

	// AddConnection creates a connection between two rooms
	AddConnection(connection Connection) error

	// RemoveConnection removes a connection
	RemoveConnection(connectionID string) error

	// GetConnection retrieves a connection by ID
	GetConnection(connectionID string) (Connection, bool)

	// GetRoomConnections returns all connections for a specific room
	GetRoomConnections(roomID string) []Connection

	// GetAllConnections returns all connections
	GetAllConnections() map[string]Connection

	// MoveEntityBetweenRooms performs a logical departure through a connection.
	// The entity is unplaced until ManagedRoomMutator.PlaceEntity selects a
	// destination position. Prefer TransitionEntity for its explicit output.
	MoveEntityBetweenRooms(entityID, fromRoom, toRoom, connectionID string) error

	// CanMoveEntityBetweenRooms checks if entity movement is possible
	CanMoveEntityBetweenRooms(entityID, fromRoom, toRoom, connectionID string) bool

	// GetEntityRoom returns which room contains the entity
	GetEntityRoom(entityID string) (string, bool)

	// FindPath finds a path between rooms using connections
	FindPath(fromRoom, toRoom string, entity core.Entity) ([]string, error)

	// GetLayout returns the current layout pattern
	GetLayout() LayoutType

	// SetLayout configures the arrangement pattern
	SetLayout(layout LayoutType) error
}

RoomOrchestrator is the legacy topology and query contract for multiple rooms. BasicRoomOrchestrator also implements the additive ManagedRoomMutator entity-membership seam; this interface stays unchanged for source compatibility.

type RoomRemovedEvent added in v0.1.1

type RoomRemovedEvent struct {
	OrchestratorID string    `json:"orchestrator_id"`
	RoomID         string    `json:"room_id"`
	Reason         string    `json:"reason,omitempty"`
	RemovedAt      time.Time `json:"removed_at"`
}

RoomRemovedEvent contains data for room removal from orchestrator events

type SightCellInput added in v0.15.0

type SightCellInput struct {
	At Position
}

SightCellInput identifies an alternate origin whose opacity should be read.

type SightCellOutput added in v0.15.0

type SightCellOutput struct {
	Blocked bool
}

SightCellOutput reports whether a cell is opaque as an alternate origin.

type SightLaneInput added in v0.15.0

type SightLaneInput struct {
	From Position
	To   Position
	Ray  []Position
}

SightLaneInput describes one lane whose obstruction facts should be read. Ray is the canonical grid ray from From toward To. Implementations must treat it as read-only and must not retain it after Along returns.

type SightLaneOutput added in v0.15.0

type SightLaneOutput struct {
	HardBlocked bool
	SoftBlocked bool
}

SightLaneOutput reports the obstruction facts for one lane. A hard block cannot be bypassed by alternate lanes; a soft block can be bypassed, though either kind blocks the lane on which it is reported.

type SightLanesInput added in v0.15.0

type SightLanesInput struct {
	Grid         Grid
	From         Position
	To           Position
	Obstructions SightObstructions
}

SightLanesInput contains the collaborators and endpoints for a sight query.

type SightLanesOutput added in v0.15.0

type SightLanesOutput struct {
	Blocked bool
}

SightLanesOutput reports whether every eligible lane is blocked.

func SightLanes added in v0.15.0

func SightLanes(in SightLanesInput) (SightLanesOutput, error)

SightLanes evaluates the direct grid lane and, for a soft direct obstruction, progress-making alternate lanes from both endpoints. A hard direct obstruction is absolute, and gridless queries evaluate only the direct lane. It returns the zero output with any validation or obstruction callback error.

type SightObstructions added in v0.15.0

type SightObstructions interface {
	Along(SightLaneInput) (SightLaneOutput, error)
	At(SightCellInput) (SightCellOutput, error)
}

SightObstructions supplies obstruction facts for a SightLanes query. Along reads a canonical lane and At reads whether an alternate origin is opaque. Implementations should expose a stable view for the whole query.

type SimpleEntityFilter

type SimpleEntityFilter struct {
	EntityTypes []string `json:"entity_types,omitempty"`
	EntityIDs   []string `json:"entity_ids,omitempty"`
	ExcludeIDs  []string `json:"exclude_ids,omitempty"`
}

SimpleEntityFilter implements basic entity filtering

func NewSimpleEntityFilter

func NewSimpleEntityFilter() *SimpleEntityFilter

NewSimpleEntityFilter creates a new simple entity filter

func (*SimpleEntityFilter) Matches

func (f *SimpleEntityFilter) Matches(entity core.Entity) bool

Matches checks if an entity matches the filter criteria

func (*SimpleEntityFilter) WithEntityIDs

func (f *SimpleEntityFilter) WithEntityIDs(ids ...string) *SimpleEntityFilter

WithEntityIDs adds entity ID filtering

func (*SimpleEntityFilter) WithEntityTypes

func (f *SimpleEntityFilter) WithEntityTypes(types ...string) *SimpleEntityFilter

WithEntityTypes adds entity type filtering

func (*SimpleEntityFilter) WithExcludeIDs

func (f *SimpleEntityFilter) WithExcludeIDs(ids ...string) *SimpleEntityFilter

WithExcludeIDs adds entity ID exclusion

type SimplePathFinder added in v0.4.0

type SimplePathFinder struct{}

SimplePathFinder uses A* algorithm with uniform movement cost. It finds the shortest path around obstacles using hex distance as heuristic.

func NewSimplePathFinder added in v0.4.0

func NewSimplePathFinder() *SimplePathFinder

NewSimplePathFinder creates a new A* pathfinder

func (*SimplePathFinder) FindPath added in v0.4.0

func (p *SimplePathFinder) FindPath(start, goal CubeCoordinate, blocked map[CubeCoordinate]bool) []CubeCoordinate

FindPath implements PathFinder using A* algorithm. Uses hex distance as heuristic (admissible - never overestimates).

func (*SimplePathFinder) FindPathWithTraversal added in v0.6.0

func (p *SimplePathFinder) FindPathWithTraversal(
	start, goal CubeCoordinate,
	blocked map[CubeCoordinate]bool,
	canTraverse TraversalPredicate,
	limit TraversalSearchLimit,
) []CubeCoordinate

FindPathWithTraversal uses A* while also requiring each adjacent step to be permitted by canTraverse. Traversal-aware searches require an explicit limit because a predicate can seal an otherwise-unblocked goal on the unbounded hex plane. It preserves legacy FindPath behavior by leaving that API unbounded.

type SpatialQueryHandler

type SpatialQueryHandler struct {
	// contains filtered or unexported fields
}

SpatialQueryHandler handles spatial query events

func NewSpatialQueryHandler

func NewSpatialQueryHandler() *SpatialQueryHandler

NewSpatialQueryHandler creates a new spatial query handler

func (*SpatialQueryHandler) HandleQuery

func (h *SpatialQueryHandler) HandleQuery(ctx context.Context, query interface{}) (interface{}, error)

HandleQuery processes spatial queries

func (*SpatialQueryHandler) RegisterRoom

func (h *SpatialQueryHandler) RegisterRoom(room Room)

RegisterRoom registers a room with the query handler

func (*SpatialQueryHandler) UnregisterRoom

func (h *SpatialQueryHandler) UnregisterRoom(roomID string)

UnregisterRoom removes a room from the query handler

type SquareGrid

type SquareGrid struct {
	// contains filtered or unexported fields
}

SquareGrid implements a square grid system using Chebyshev distance

func NewSquareGrid

func NewSquareGrid(config SquareGridConfig) *SquareGrid

NewSquareGrid creates a new square grid with the given dimensions

func (*SquareGrid) Distance

func (sg *SquareGrid) Distance(from, to Position) float64

Distance calculates Chebyshev distance: max(|x2-x1|, |y2-y1|) This means diagonals cost the same as orthogonal movement

func (*SquareGrid) GetDimensions

func (sg *SquareGrid) GetDimensions() Dimensions

GetDimensions returns the grid dimensions

func (*SquareGrid) GetLineOfSight

func (sg *SquareGrid) GetLineOfSight(from, to Position) []Position

GetLineOfSight returns positions along the line of sight between two positions Uses Bresenham's line algorithm for grid-based line drawing

func (*SquareGrid) GetNeighbors

func (sg *SquareGrid) GetNeighbors(pos Position) []Position

GetNeighbors returns all 8 adjacent positions (including diagonals)

func (*SquareGrid) GetPositionsInCircle

func (sg *SquareGrid) GetPositionsInCircle(circle Circle) []Position

GetPositionsInCircle returns all positions within a Chebyshev-radius area

func (*SquareGrid) GetPositionsInCone

func (sg *SquareGrid) GetPositionsInCone(
	origin Position, direction Position, length float64, angle float64,
) []Position

GetPositionsInCone returns positions within a cone shape This is a simplified cone implementation - games may need more sophisticated cone logic

func (*SquareGrid) GetPositionsInLine

func (sg *SquareGrid) GetPositionsInLine(from, to Position) []Position

GetPositionsInLine returns positions along a line from start to end

func (*SquareGrid) GetPositionsInRange

func (sg *SquareGrid) GetPositionsInRange(center Position, radius float64) []Position

GetPositionsInRange returns all positions within a given Chebyshev distance

func (*SquareGrid) GetPositionsInRectangle

func (sg *SquareGrid) GetPositionsInRectangle(rect Rectangle) []Position

GetPositionsInRectangle returns all positions within a rectangular area

func (*SquareGrid) GetShape

func (sg *SquareGrid) GetShape() GridShape

GetShape returns the grid shape type

func (*SquareGrid) IsAdjacent

func (sg *SquareGrid) IsAdjacent(pos1, pos2 Position) bool

IsAdjacent checks if two positions are adjacent (within 1 square, including diagonals)

func (*SquareGrid) IsValidPosition

func (sg *SquareGrid) IsValidPosition(pos Position) bool

IsValidPosition checks if a position is valid within the grid bounds

type SquareGridConfig

type SquareGridConfig struct {
	Width  float64
	Height float64
}

SquareGridConfig holds configuration for creating a square grid

type TransitionEntityInput added in v0.8.0

type TransitionEntityInput struct {
	EntityID     core.EntityID
	FromRoom     RoomID
	ToRoom       RoomID
	ConnectionID ConnectionID
}

TransitionEntityInput names a logical transition through a connection.

type TransitionEntityOutput added in v0.8.0

type TransitionEntityOutput struct {
	Entity     core.Entity
	Departure  EntityRemovalDelta
	Transition EntityTransitionDelta
}

TransitionEntityOutput returns the removed entity, its departure, and the logical transition that a composition must finish with PlaceEntity.

type TraversalPathFinder added in v0.6.0

type TraversalPathFinder interface {
	// FindPathWithTraversal returns a path while honoring blocked cells and a
	// traversal predicate. A nil predicate permits every adjacent crossing.
	// Search is bounded by limit; it returns an empty slice when no route fits
	// within the bound or when no route exists.
	FindPathWithTraversal(
		start, goal CubeCoordinate,
		blocked map[CubeCoordinate]bool,
		canTraverse TraversalPredicate,
		limit TraversalSearchLimit,
	) []CubeCoordinate
}

TraversalPathFinder is an optional extension for path finders that can consult a traversal predicate in addition to legacy blocked cells.

type TraversalPredicate added in v0.6.0

type TraversalPredicate func(from, to CubeCoordinate) bool

TraversalPredicate decides whether A* may traverse one directed adjacent pair. It must return true to permit the step from to. A predicate can model blocked crossings such as closed boundaries without converting them into blocked cells.

type TraversalSearchLimit added in v0.6.0

type TraversalSearchLimit struct {
	// MaxSteps is the inclusive path-length and search-cost bound.
	MaxSteps int
}

TraversalSearchLimit bounds predicate-aware searches on the otherwise unbounded hex plane. MaxSteps is the inclusive maximum number of crossings in a returned path. A non-positive limit permits no path unless start equals goal, and a limit shorter than the direct distance cannot reach the goal.

The limit is also a deterministic safety contract: A* never adds a position whose path cost exceeds MaxSteps, so a predicate that seals an unblocked goal terminates after examining a finite search area. Callers should set MaxSteps above the direct distance by enough steps for the detours their map allows.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL