Skip to content

Stairs

Vanilla Stairs : Structure, ISmartRotatable (decompile line 307764). A third grid-registration category alongside LargeStructure and SmallGrid: a stairs is a plain large-grid Structure (NOT a LargeStructure, NOT a SmallGrid), tracked specially by the dedicated Cell.Stairs slot and the Structure.IsStairs flag. For the wider subclass tree see Structure.md.

Class shape

Members (decompile lines 307764-307783):

public Transform EntryPoint;
public Transform ExitPoint;
private Grid3 _entryPosition;
private Grid3 _exitPosition;
[Header("ISmartRotation")]
public SmartRotate.ConnectionType ConnectionType = SmartRotate.ConnectionType.Exhaustive;
public int[] OpenEndsPermutation = new int[6] { 0, 1, 2, 3, 4, 5 };
public bool RequiresFrame;
public Grid3 Entry => _entryPosition;
public Grid3 Exit => _exitPosition;

Entry / Exit are the climb endpoints. ISmartRotatable (the ConnectionType + OpenEndsPermutation pair) is shared with Ladder. GetStationpediaCategory returns the Safety category. RequiresFrame gates placement on a mounting structure behind the stairs (CanConstruct, lines 307785-307807).

Large-grid registration

OnRegistered(Cell) registers a stairs against the large Cell grid (contrast SmallGrid and ladders, which register on the 0.5 m SmallCell small grid, see SmallCell.md):

public override void OnRegistered(Cell cell)
{
    base.OnRegistered(cell);
    if ((bool)EntryPoint && (bool)ExitPoint)
    {
        _entryPosition = base.GridController.WorldToLocalGrid(EntryPoint.position);
        _exitPosition = base.GridController.WorldToLocalGrid(ExitPoint.position);
    }
}

A stairs is therefore present in Cell.AllStructures and occupies the dedicated Cell.Stairs slot (see Cell.md). CanConstructCell (lines 307828-307858) reads allStructure.IsStairs and per-cell StructureCollisionType values to block overlapping placement.

IsStairs, the Cell.Stairs slot, and the Entry/Exit run model

There is no separate Stairwell class in Assembly-CSharp (the string does not appear anywhere in the assembly); a "stairwell" is a prefab built on the Stairs class. The game's canonical "is this a stairs" test is Structure.IsStairs (decompile lines 295378-295384):

public bool IsStairs
{
    get
    {
        if (!(GetType() == typeof(Stairs)))
            return GetType().IsSubclassOf(typeof(Stairs));
        return true;
    }
}

So IsStairs (equivalently a C# is Stairs test) matches Stairs and any subclass, covering every stairs/stairwell prefab in one check.

On registration, Cell.Add routes a Stairs into the dedicated Cell.Stairs slot (decompile lines 272258-272261):

Stairs stairs = structure as Stairs;
if ((bool)stairs)
    Stairs = stairs;

NPC pathing walks a connected stair run through the Entry / Exit grid points, not raw cell adjacency: GetStairExitPoint / GetStairEntryPoint (decompile lines 272840-272905) recurse from a stair's Exit to the stair whose Entry matches (cell.Stairs.Entry == startGrid), and vice versa, chaining the run floor by floor. Because a stair climbs up AND forward, consecutive stairs are diagonally offset, so the Entry/Exit chain (not orthogonal cell adjacency) is the reliable way to enumerate a run.

Stairwell variants (zero Entry/Exit)

The buildable stairwells are all the Stairs class, not a separate type. The variants observed are StructureStairwellFrontPassthrough, StructureStairwellBackPassthrough, StructureStairwellNoDoors, StructureStairwellFrontLeft, StructureStairwellFrontRight, StructureStairwellBackLeft, and StructureStairwellBackRight (display names "Stairwell (Front Passthrough)", "Stairwell (Back Right)", etc.); the in-game kit lists eight. Unlike an angled flight, a stairwell is a vertical pass / door piece with no climb, so the game leaves its Entry and Exit at (0,0,0). Angled Stairs flights set Entry / Exit from their EntryPoint / ExitPoint transforms in OnRegistered (see above). So a runtime Entry == Exit == Grid3.zero test reliably separates a stairwell from an angled flight, even though both are the same C# class.

Verified via InspectorPlus on 2026-06-19 in game version 0.2.6228.27061. Request: types=[Stairs], fields=[ReferenceId, DisplayName, PrefabHash, Position, Entry, Exit, Forward] over a stacked column built from each variant; every stairwell variant reported Entry and Exit of (0,0,0), while adjacent angled StructureStairs4x2 flights carried non-zero Entry/Exit.

Flood / traversal implication

Because Stairs : Structure is not a LargeStructure, a flood keyed on thing is LargeStructure (the SprayPaintPlus large-structure grid flood) does not catch stairs; a sprayed stairs falls through every paint branch and only the single piece is recolored. Stairs live on the large grid (Cell / GetCell, the Cell.Stairs slot), not the 0.5 m small grid, so they are cheaper to reach than ladders. But a plain 6-neighbor orthogonal BFS will NOT connect a multi-floor run, because stacked stairs are diagonally offset (see the Entry/Exit model above). To walk a run, hop along the Entry/Exit chain: from a stair, take the Cell.Stairs occupying the cell at its Exit and the cell at its Entry, recursing. An orthogonal-neighbor scan can be layered on top to catch side-by-side stairs of the same prefab. Filter by is Stairs (base type) to include any stairwell subclass.

GridPosition is the bounds centre, and it need not name the cell the piece registered in

Thing.GridPosition is CenterPosition.ToGrid(), and CenterPosition is Position + Rotation * Bounds.center (decompile, Thing). For a piece whose footprint spans more than one cell that names the cell at the MIDDLE of the footprint, which is not necessarily the cell the piece registered in. Thing.Cell holds the registration cell, and the two disagree on every stairwell measured.

A structure is added to AllStructures of every cell of its footprint, not just one. Cell.Add(Structure, Grid3 relativePosition) is called per occupied cell, and for CollisionType.BlockCustom, which is the branch that also fills the Cell.Stairs slot, it appends to AllStructures unconditionally:

public bool Add(Structure structure, Grid3 relativePosition)
{
    switch (structure.StructureCollisionType)
    {
    case CollisionType.BlockCustom:
    {
        Stairs stairs = structure as Stairs;
        if ((bool)stairs)
        {
            Stairs = stairs;
        }
        AllStructures.Add(structure);
        break;
    }
    ...

Cell.NeighborCells is built once in the Cell constructor from GridController.GetNeighborCells(Grid, span, ref count, includeCorners: true) over a 32-element span, so it is the full 26-cell set and nothing wider.

Measured live on 2026-09-23 through the TestRig control plane (GET /thing, fields GridPosition, Cell.Grid, Entry, Exit) against the Spray Paint Plus fixture world, a switchback stairwell shaft of four pieces at x -1293:

ReferenceId PrefabName Position GridPosition Thing.Cell.Grid
367743 StructureStairwellFrontPassthrough (-1293, 193, -747) Grid3(-12930, 1930, -7490) Grid3(-12930, 1930, -7470)
367731 StructureStairwellNoDoors (-1293, 195, -751) Grid3(-12930, 1950, -7490) Grid3(-12930, 1950, -7510)
367733 StructureStairwellNoDoors (-1293, 197, -747) Grid3(-12930, 1970, -7490) Grid3(-12930, 1970, -7470)
367700 StructureStairwellNoDoors (-1293, 199, -751) Grid3(-12930, 1990, -7490) Grid3(-12930, 1990, -7510)

Every piece reported Entry and Exit of Grid3(0, 0, 0), confirming the stairwell test above on a fifth prefab pair.

The consequence is the one that matters for a cell flood. By Position the pieces alternate between two landing cells two apart in z and would not be 26-neighbours of each other. By GridPosition all four sit in the single z -7490 column, one cell apart in y, which is an orthogonal chain. A flood that starts at GetCell(origin.GridPosition) and expands into neighbouring cells holding a stairwell therefore reaches all four, and a live stroke confirmed it: the Spray Paint Plus stairwell flood painted exactly these four and nothing else within an 80 m scan of 26723 Things.

Angled flights behave differently again, and not uniformly: StructureStairs4x2 484585 at (-1245, 261, -461) facing -z reported GridPosition and Cell.Grid both Grid3(-12450, 2610, -4630), two metres back along its facing from Position; 484612 at (-1223, 261, -453) facing -x reported GridPosition Grid3(-12230, 2610, -4530) against Cell.Grid Grid3(-12250, 2610, -4530). So neither "they always agree" nor "they always differ" holds. Code that walks cells must decide which of the two it means and say so.

An angled flight's GridPosition sits on a cell boundary, and the tie breaks to even

The 2026-09-23 reading above recorded that angled flights sometimes agree with Thing.Cell and sometimes do not, and left it at "neither rule holds". The arithmetic is now measured.

StructureStairs4x2 reports Bounds Center: (0.00, 0.10, 1.00), Extents: (1.00, 1.10, 2.00) on every instance. So CenterPosition = Position + Rotation * (0, 0.1, 1), which for a -z-facing flight is Position + (0, 0.1, -1): the z component lands on an even metre, which is a large-cell boundary, because every cell centre in this world is an odd metre. GridPosition is CenterPosition.ToGrid(), whose 2 m snap is Mathf.Round((z - 1) / 2) * 2 + 1, and Mathf.Round breaks a .5 tie to even. A boundary value therefore resolves to the cell on one side or the other according to the parity of half its own centre coordinate, and two staircases whose centres differ by 2 m resolve opposite ways.

Measured live on 2026-09-23 through the TestRig control plane (GET /thing, fields Position, CenterPosition, GridPosition, Cell.Grid, Bounds) against the Spray Paint Plus fixture world, all six flights -z-facing with Forward 4.20411152E-06,0,-1:

ReferenceId Position CenterPosition (z-1)/2 GridPosition Thing.Cell.Grid
484585 (-1245, 261, -461) (-1245, 261.100067, -462) -231.5 -> -232 Grid3(-12450, 2610, -4630) Grid3(-12450, 2610, -4630)
484581 (-1245, 263, -457) (-1245, 263.100067, -458) -229.5 -> -230 Grid3(-12450, 2630, -4590) Grid3(-12450, 2630, -4590)
484579 (-1245, 265, -453) (-1245, 265.100067, -454) -227.5 -> -228 Grid3(-12450, 2650, -4550) Grid3(-12450, 2650, -4550)
509001 (-1257, 261, -467) (-1257, 261.100067, -468) -234.5 -> -234 Grid3(-12570, 2610, -4670) Grid3(-12570, 2610, -4690)
509002 (-1257, 263, -463) (-1257, 263.100067, -464) -232.5 -> -232 Grid3(-12570, 2630, -4630) Grid3(-12570, 2630, -4650)
509003 (-1257, 265, -459) (-1257, 265.100067, -460) -230.5 -> -230 Grid3(-12570, 2650, -4590) Grid3(-12570, 2650, -4610)

The first three resolve one cell BACK along the facing and agree with Thing.Cell; the last three resolve one cell FORWARD and disagree with it by exactly one cell. Nothing about the two groups differs except that their centres are 2 m apart in z. A -x-facing flight shows the same thing on the x axis: 484612 at (-1223, 261, -453) has CenterPosition (-1224, 261.100067, -453), GridPosition Grid3(-12230, 2610, -4530) and Cell.Grid Grid3(-12250, 2610, -4530).

The practical consequence for anything that walks cells from a flight: do not compute a flight's cell from its Position, and do not assume two flights of one staircase sit the same way relative to their own registration cell. Read GridPosition if that is the value the walk starts from (it is what Cell.Stairs-style lookups off GridController.GetCell want), read Thing.Cell if registration is the question, and never derive one from the other. It does not disturb a relative-offset walk: each of the two runs above steps its own GridPosition by (0, +20, +40) from flight to flight, which is one cell in y and two in z either way.

Verification history

  • 2026-09-23: added "An angled flight's GridPosition sits on a cell boundary, and the tie breaks to even". Measured through the TestRig control plane on game 0.2.6428.27798, over six StructureStairs4x2 in two staircases, reading Bounds, CenterPosition, GridPosition and Cell.Grid together for the first time. REFINES rather than contradicts the section below it: that section recorded the disagreement between GridPosition and Thing.Cell on angled flights and said plainly that neither "they always agree" nor "they always differ" holds, which this section now explains. No claim there is withdrawn. The three flights at x -1257 are authored into the Spray Paint Plus fixture rather than played into it, which is what put two staircases with different centre parity in one world; see Mods/SprayPaintPlus/playtests/fixtures/SprayPaintPlusWorld.manifest.json.
  • 2026-09-23: added "GridPosition is the bounds centre, and it need not name the cell the piece registered in" from Cell.Add, the Cell constructor and a live GET /thing read of four stairwells and two angled flights on game 0.2.6428.27798. Additive; it confirms rather than contradicts the "Flood / traversal implication" section's claim that stacked angled flights are not orthogonally adjacent. Resolves the first half of the open question below. The playtest that rests on it is a stroke floods its own block for stairwells and large structures in Mods/SprayPaintPlus/playtests/.
  • 2026-06-19: page created while scoping ladder and stairs flood-painting for Spray Paint Plus. Verbatim from Assets.Scripts.Objects.Stairs (class shape, CanConstruct, OnRegistered, CanConstructCell), game version 0.2.6228.27061. Additive; new page. The hierarchy fact Stairs : Structure is also noted in Structure.md.
  • 2026-06-19: added "IsStairs, the Cell.Stairs slot, and the Entry/Exit run model" section (Structure.IsStairs verbatim, Cell.Add stairs routing, the GetStairExitPoint/GetStairEntryPoint pathing chain) and corrected the "Flood / traversal implication" section: stacked stairs are diagonally offset and connect via Entry/Exit, so a plain orthogonal BFS does not walk a run. Confirmed no Stairwell class exists in Assembly-CSharp. Sources: Structure.IsStairs, Cell.Add, Assets.Scripts.GridSystem stair-pathing helpers, game version 0.2.6228.27061.
  • 2026-06-19: added "Stairwell variants (zero Entry/Exit)" section from an InspectorPlus capture of a stacked column built from each stairwell variant; every variant reports Entry / Exit of (0,0,0) while adjacent angled StructureStairs4x2 flights do not, giving a reliable runtime stairwell-versus-flight test. Game version 0.2.6228.27061.

Open questions

  • The correct checker parity for a stair run (the large-grid CheckeredCheckGrid versus an elevator-style sort-and-alternate) is still unconfirmed: the 2026-09-23 live run drove the two floods with the checkered modifier off, so nothing has measured the pattern itself.

Resolved:

  • Why GridPosition and Thing.Cell agree on some angled flights and not others. Answered 2026-09-23: the flight's CenterPosition lands on a cell boundary and the 2 m snap breaks the tie to even. See "An angled flight's GridPosition sits on a cell boundary" above.
  • Whether stacked multi-floor stairs sit in orthogonally-adjacent large cells so a 6-neighbour BFS connects them. Measured 2026-09-23, and it depends on which of the two grid values the walk reads: by GridPosition a switchback stairwell shaft IS one orthogonal cell column, while consecutive angled flights step one cell in y and two in z and are not adjacent at all. See "GridPosition is the bounds centre" above.