-
Notifications
You must be signed in to change notification settings - Fork 1
ROOMS HEIGHTMAP
Third page of the Rooms section. A room isn't flat, and it isn't fully 3D either. Every classic client renders a 2.5D world: a grid of tiles, each with its own base height, where furniture can add walkable surfaces above that base. This page covers how Pixels represents that: a fixed-point height unit, a base grid, and a resolver that layers dynamic furniture on top of it to produce the actual walkable surface at any tile.
Room height, on the wire and in the classic tools, is a decimal room unit (2.5, 1.0). Storing that as a float everywhere invites the usual float-comparison bugs in pathfinding, so the world package converts it once, at the boundary, into a fixed-point integer scaled by four:
type Height int16
const (
// HeightScale stores the number of fixed-point steps in one room height unit.
HeightScale Height = 4
// AvatarClearance stores the standing avatar clearance in fixed-point units.
AvatarClearance Height = 8
)
func HeightFromUnits(value float64) Height {
return Height(math.Round(value * float64(HeightScale)))
}
func (height Height) Units() float64 {
return float64(height) / float64(HeightScale)
}Four steps per unit means quarter-unit precision. The smallest height difference the world can represent is 0.25, which matches the finest step height real furniture definitions use. AvatarClearance of 8 fixed-point units is exactly two room units: how much headroom a standing avatar needs above a surface before something above it counts as blocking. Every height comparison in pathfinding and stacking happens in this integer space; the decimal form only exists at the packet encode/decode boundary (grid.HeightFromUnits / Height.String()), so nothing downstream ever compares floats.
grid.Grid is the immutable per-room heightmap parsed once from the room's floor plan: a flat base height and a couple of flags (invalid tile, door tile) per point, indexed by y*width+x. It answers exactly the questions a static floor plan can answer: is this tile part of the room, what's its base height, is it the door, and nothing about what's currently placed on it. That's deliberately the surface resolver's job, not the grid's.
A tile in a 2.5D room can have more than one usable height at once: the floor itself, plus the top of a rug, plus the seat of a chair on that rug, each a distinct walkable "section." surface.Resolver is what turns the static base grid plus whatever furniture currently occupies a tile into that list of sections:
type Resolver struct {
grid grid.Grid
fixtures map[int][]Fixture // dynamic furniture-derived fixtures, grouped by grid index
versions map[int]uint32 // per-tile version, bumped on every fixture change
}
func (resolver *Resolver) Column(point grid.Point) (Column, error)
func (resolver *Resolver) SectionAt(point grid.Point, height grid.Height) (Section, error)Each Section records a height (z), a movement State, and where it came from:
type State uint8
const (
StateInvalid State = iota
StateOpen // walkable
StateBlocked // occupies the tile but isn't walkable
StateSit // walkable and usable as a seat target
StateLay // walkable and usable as a lay target
)
type Source uint8
const (
SourceBase Source = iota // the static floor plan itself
SourceFixture // a generic placed item (its footprint plus StackHeight)
SourceStack // a manual override from the stack helper, see FURNITURE-ADVANCED
SourceGate // a gate interaction's walkable/blocked toggle
)Column stores a small inline array of sections (eight, before falling back to a heap slice for the rare tile with more) and a monotonic version bumped every time a fixture is added or removed at that tile. The version is how callers can tell a cached column is stale without re-fetching it defensively on every read.
AddSection is where the composition rules live, and its comment states the invariant directly:
// AddSection adds a resolved tile section, letting a blocking, sit, or lay section replace a
// tied-height section rather than duplicate it, since a tile can only have one such terminal
// state at a given height.
func (column *Column) AddSection(section Section) {
column.removeCoveredWalkableSections(section)
if section.state.replacesTiedSection() && column.replaceTiedSection(section) {
return
}
...
}Two rules fall out of this. First, a new section covers (removes) any walkable section beneath it that no longer has enough AvatarClearance above it to stand on: placing a low-clearance item over an existing open section removes that section from the walkable set rather than leaving a phantom floor a unit could still be routed onto. Second, StateBlocked, StateSit, and StateLay are terminal states: a tile can't simultaneously be a sit target and a lay target at the same height, so a new terminal section at a height that's already occupied replaces the old one instead of stacking a duplicate. StateOpen sections don't have this restriction; several open sections can coexist at different heights on the same tile, which is exactly what lets a rug on the floor and a chair's seat both be valid, distinct walkable heights on one point.
Reading a column back for pathfinding goes through two helpers rather than a raw section index:
func (column Column) WalkableSectionAt(height grid.Height) (Section, bool) // exact height match
func (column Column) NearestWalkableSection(height grid.Height) (Section, bool) // closest usable height
func (column Column) Accepts(section Section) bool // walkable + enough headroomAccepts is where AvatarClearance actually gets enforced: a section only counts as usable if nothing else in the column occupies the space between its height and height + AvatarClearance.
Moving between adjacent tiles isn't free of height constraints. Pathfinding rejects a step whose height delta is too large to be a normal walk-up, forcing stairs or a ramp instead:
type Rules struct {
MaxStepUp grid.Height
...
}
var defaults = Rules{
MaxStepUp: 6, // 1.5 room units, in quarter-unit fixed point
}6 fixed-point units is 1.5 real room units. A unit can step up onto a low stack or a stair tread but not straight onto a tall platform; reaching a tall surface has to come through several smaller steps or a dedicated ramp-shaped set of sections rather than one large jump.
The stack helper (SourceStack, covered in FURNITURE-ADVANCED) exists precisely because SourceFixture's automatic footprint-plus-StackHeight derivation doesn't always match what a room designer wants: it lets a specific placed item's contribution to the column be pinned to an exact height instead of the definition's computed one, without touching the catalog definition itself. Because AddSection's tie-breaking and clearance rules apply uniformly regardless of Source, a manually pinned section composes with everything else on the tile exactly the same way an automatic one would. The override changes what height gets contributed, not how stacking resolves once it's there.
Pixels
Getting Started
Architecture
Architecture Internals
Authentication
Users
Navigator
Inventory
Furniture
Rooms
Decoration
Games
Plugins
- PLUGINS-OVERVIEW
- PLUGINS-CREATING
- PLUGINS-LISTENERS
- PLUGINS-EVENTS-REALMS
- PLUGINS-EVENTS-ECONOMY-ROOMS
- PLUGINS-EVENTS-MODERATION-TRADES
- PLUGINS-EVENTS-COMMERCE-WORLD
- PLUGINS-EVENT-FURNITURE-MOVE
- PLUGINS-EVENT-FURNITURE-PICKUP
- PLUGINS-EVENT-ROOM-CREATE
- PLUGINS-EVENT-MARKETPLACE-LIST
- PLUGINS-EVENT-MARKETPLACE-BUY
- PLUGINS-EVENT-PLAYER-PROFILE-UPDATE
- PLUGINS-EVENT-BOT-SPEECH
- PLUGINS-EVENT-GROUP-MEMBERSHIP-CHANGE
- PLUGINS-EVENT-MESSENGER-FRIEND-REQUEST
- PLUGINS-EVENT-MESSENGER-FRIEND-ACCEPT
- PLUGINS-EVENT-CRAFTING-CRAFT
- PLUGINS-WIRED
- WIRED
- PLUGINS-COMMANDS
- PLUGINS-SDK
- PLUGINS-DEPLOYMENT