Migrating to Mibo v6
What v6 is
v6 puts every grid behind one storage type and one authoring DSL:
-
One grid type.
CellGrid2D<'T>stores squares and hexes. Hex is a geometry setting (CellGrid2D.createHex), not a separate API. - One authoring model. The Flow DSL authors both geometries: grid template areas, flexbox rows and columns, docks, and landmark queries (named rectangles and tags).
-
3D becomes a heightmap. The vertical axis moves into the tile
(
{ Kind: BlockKind; Height: int }). The grid stores the footprint.
Nothing breaks compilation today. Every retired module carries
[<Obsolete>] and still works. You can migrate one level at a time
while the compat surface still compiles.
One rule covers every migration in this page: the grid stores a 2D footprint, and everything vertical becomes data in the tile.
1. Recompile and read the warnings
Build your project. Every FS0044 warning marks a call site that must migrate. The obsolete message names the replacement. Nothing else changes: the kernel, both runtimes, and both backends keep their namespaces and signatures.
2. Migrate hex grids to CellGrid2D.createHex
Hex grids are CellGrid2D with hex geometry. Storage, the Flow DSL,
and landmarks stay the same. Geometry affects two things only: world
positions (rows or columns stagger) and spatial queries
(Hex2DSpatial replaces Grid2DSpatial).
Storage
// before
let grid = HexGrid.create 20 15 32f Vector2.Zero PointyTop
// after
let grid =
CellGrid2D.createHex {
Orientation = HexOrientation.PointyTop
Width = 20
Height = 15
Radius = 32f
Origin = Vector2.Zero
}
Cell access
set, get, clear, iter, and getWorldPos moved to CellGrid2D
with identical arguments and behavior. The grid's geometry applies the
hex stagger:
// before // after
HexGrid.set 5 3 tile grid CellGrid2D.set 5 3 tile grid
HexGrid.get 5 3 grid CellGrid2D.get 5 3 grid
HexGrid.getWorldPos 5 3 grid CellGrid2D.getWorldPos 5 3 grid
Culling
CellGrid2D.iterVisible culls hex grids too, with an
orientation-aware window. The bounds type changes: the retired
HexGrid.iterVisible took float32 bounds, the unified one takes
int:
// before (float32 bounds) // after (int bounds)
HexGrid.iterVisible l t r b action grid CellGrid2D.iterVisible
(int l) (int t) (int r) (int b)
action grid
Pipelines
HexLayout pipelines map one-to-one onto the square Layout ops. Hex
storage runs them unchanged. You can also author with
Flow:
// before: HexLayout
let board =
HexGrid.create 12 10 48f origin FlatTop
|> HexLayout.run(fun section ->
section
|> HexLayout.fill 0 0 12 10 Grass
|> HexLayout.border 0 0 12 10 Water)
// after: the same Layout ops over hex storage
let board =
CellGrid2D.createHex {
Orientation = HexOrientation.FlatTop
Width = 12
Height = 10
Radius = 48f
Origin = origin
}
|> Layout.run(fun section ->
section
|> Layout.fill 0 0 12 10 Grass
|> Layout.border 0 0 12 10 Water)
Spatial queries
Hex2DSpatial keeps its signatures. Its grid parameters are
CellGrid2D<'T> now, so it no longer emits deprecation warnings:
// unchanged calls, no warnings
let steps = Hex2DSpatial.distance 5 3 9 7 board
let ring = Hex2DSpatial.ring 5 3 2 board
let path = Hex2DSpatial.findPath sc sr gc gr passable (fun _ _ _ _ -> 1f) board
Hex2DSpatial.worldToCell, neighbors, inRange, spiral, and
floodFill are unchanged as well.
3. Migrate 3D levels to footprint plus column height
The entire Mibo.Layout3D family is retired: CellGrid3D,
GridSection3D, the Layout3D DSL, HexGrid3D / HexLayout3D,
Grid3DSpatial / Hex3DSpatial, the layered 3D grids, and the 3D grid
renderers.
The model shift
A CellGrid3D<'T> stored one 'T voption per voxel. The vertical axis
was an index. The replacement stores one tile per footprint cell, and
the vertical extent rides in the tile:
// before: the voxel was the unit
type Cell = | Floor | Wall | Chest
// after: the column is the unit. Kind plus vertical extent.
type BlockKind =
| Floor
| Wall
| Door
| Brick
| Stone
| Crate
| Step
type Column = { Kind: BlockKind; Height: int }
let floor = { Kind = Floor; Height = 0 }
let wall = { Kind = Wall; Height = 3 }
let door = { Kind = Door; Height = 2 }
What each old concept becomes:
Before |
After |
|---|---|
|
|
The Y axis |
|
|
|
|
|
|
|
|
The |
|
|
|
|
|
|
|
|
|
No direct equivalent. Author the footprint ( |
|
|
|
|
|
The volume-culled instancing pattern below |
|
|
A worked level
The retired docs built a dungeon from stamp composition. Before (from the 3D Layout Engine page):
module Dungeon =
let cell = room 5 5 5 FloorCell WallCell CeilingCell
let corridor length =
Layout3D.fill 0 0 0 length 3 3 FloorCell
>> Layout3D.shell 0 0 0 length 3 3 WallCell
let intersection =
cell
>> Layout3D.clear 2 0 2 1 5 1 // North door
>> Layout3D.clear 2 0 0 1 5 1 // South door
>> Layout3D.clear 0 2 2 5 1 1 // West door
>> Layout3D.clear 0 2 0 5 1 1 // East door
level
|> Layout3D.run (fun section ->
section
|> Layout3D.section 0 0 0 Dungeon.cell
|> Layout3D.section 5 1 0 (Dungeon.corridor 10)
|> Layout3D.section 15 0 0 Dungeon.intersection)
After: the same level as one Flow document over height-carrying columns. Rooms are grid areas, corridors are strips, walls are border styles, and doors are cleared border cells:
type Column =
| Floor
| Wall of height: int
| Door
let wall = Wall 3
// one 5x5 room: floor with a wall ring
let room = Stamp.box 5 5 [ Flow.fill floor; Flow.border wall ]
// a corridor: a 10x3 floor strip with a wall ring, open at the short
// sides' middle cells so rooms connect
let corridor =
Stamp.box 10 3 [
Flow.fill floor
Flow.border wall
Flow.cell { X = 0; Y = 1 } floor
Flow.cell { X = 9; Y = 1 } floor
]
// the intersection: a room with all four doorways open
let intersection =
Stamp.box 5 5 [
Flow.fill floor
Flow.border wall
Flow.cell { X = 2; Y = 0 } door
Flow.cell { X = 2; Y = 4 } door
Flow.cell { X = 0; Y = 2 } door
Flow.cell { X = 4; Y = 2 } door
]
let levelDoc =
Flow.row FlowOpts.Default [ room; corridor; intersection ]
let grid = CellGrid2D.create 20 5 cellSize origin
let struct (level, marks) = grid |> Flow.run levelDoc
Height appears in gameplay and rendering. It never appears in the layout math.
World positions
Before, the grid gave you a Vector3 per voxel. After, the grid gives
the footprint position (Vector2), and your renderer lifts it by the
column height:
// before
let worldPos = CellGrid3D.getWorldPos x y z grid // Vector3
// after
let footprint = CellGrid2D.getWorldPos x y grid // Vector2 (XZ)
let worldPos =
Vector3(
footprint.X,
float32 column.Height * cellHeight,
footprint.Y
)
Rendering
The retired CellGridRenderer3D / HexGrid3DRenderer and the
renderCellGrid / renderHexGrid buffer members are gone. The draw
surface stays: iterate your grid and feed drawInstanced your own
transforms. Per column, scale the unit block by its height:
// before
grid
|> CellGrid3D.iter (fun x y z content ->
let worldPos = CellGrid3D.getWorldPos x y z grid
spawnModel worldPos content)
// after: one instance per column, scaled to its height
let mutable n = 0
grid
|> CellGrid2D.iter (fun x y column ->
let footprint = CellGrid2D.getWorldPos x y grid
let height = float32 column.Height
transforms.[n] <-
Matrix4x4.CreateScale(cellW, height, cellD)
* Matrix4x4.CreateTranslation(footprint.X, 0f, footprint.Y)
n <- n + 1)
// raylib: buffer.AddDrawInstanced(mesh, transforms, material, n, ValueNone)
// MonoGame: Draw3D.drawInstanced(mesh, transforms, material, n, ValueNone)
Volume-culled instanced rendering
renderCellGridVolumeInstanced / renderHexGridVolumeInstanced (and
their ...WithEffect variants) have direct replacements.
InstancedRenderContext now renders 2D footprint grids itself:
RenderInstanced for the whole grid and RenderWindowInstanced for a
world-space window (hex-aware). The BoundingBox's XZ extent becomes
the window. The vertical axis is your column heights.
The window takes int world coordinates. The retired volume path
took float32 world units, so cast your camera rect when you port:
// before: the retired voxel renderer drove the context
CellGridRenderer3D.renderVolumeInstanced ctx bounds grid buffer
CellGridRenderer3D.renderVolumeInstancedWithEffect ctx bounds grid shaderForKey buffer
// after: the context renders the footprint grid. The XZ extent of the
// old BoundingBox becomes the int world-space window.
ctx.RenderWindowInstanced(buffer, int left, int top, int right, int bottom, footprintGrid)
ctx.RenderWindowInstancedWithEffect(
buffer, int left, int top, int right, int bottom, footprintGrid, shaderForKey)
// or through the Draw DSL (both backends resolve the witness)
Draw.renderFootprintInstanced(buffer, ctx, footprintGrid) |> ignore
Draw.renderFootprintWindowInstanced(buffer, ctx, int left, int top, int right, int bottom, footprintGrid)
|> ignore
The only construction change is the transform function. It receives
the column's base world position (the footprint position lifted to
y = 0), and you scale the unit block by the column's height there:
let columnTransform (basePos: Vector3) (col: Column) =
Matrix4x4.CreateScale(cellW, float32 col.Height, cellD)
* Matrix4x4.CreateTranslation(basePos)
let keyOf (col: Column) = col.Kind
let ctx =
InstancedRenderContext(
getKey = keyOf,
getMeshesAndMaterial = meshesFor,
getTransform = columnTransform)
Everything else behaves as before. ResetFrameBuffers() returns the
pooled snapshots each frame. Groups persist between frames, so only
ResizeArray growth allocates. One instanced draw is emitted per key.
A wall column with Height = 3 is one instance scaled three cells
tall. The instance count follows footprint complexity, not volume.
Pathfinding and spatial queries
Grid3DSpatial.findPath walked voxels. Walk the footprint instead. A
step is legal when the destination column is walkable (floor, or a
ramp whose height difference you accept):
// before
let path = Grid3DSpatial.findPath x1 y1 z1 x2 y2 z2 isWalkable grid
// after: paths over the footprint, heights in the predicate
let canEnter (x: int) (y: int) : bool =
CellGrid2D.get x y grid
|> ValueOption.map(fun c -> c.Kind = Floor)
|> ValueOption.defaultValue false
// costFn: (fromX, fromY, toX, toY) -> step cost; 1f for uniform terrain
let path = Grid2DSpatial.findPath x1 y1 x2 y2 canEnter (fun _ _ _ _ -> 1f) grid
Ramps and stairs become footprint tiles with intermediate Height
values. A ramp of rise 5 is a run of columns with heights 0..5.
Your predicate accepts a step when the height difference is 1. The
retired Terrain and
Interior stamp pages remain as
references for those shapes.
4. Replace layered grids with your own dictionary
LayeredGrid2D / LayeredHexGrid (and the 3D siblings) managed a
dictionary of grids keyed by layer index. A dictionary of grids is
something game code can own directly:
// before
let level =
LayeredGrid2D.create 50 30 cellSize origin
|> LayeredLayout.layer 0 (fun s -> s |> Layout.fill 0 0 50 30 Terrain)
|> LayeredLayout.layer 1 (fun s -> s |> Layout.fill 2 2 10 8 Structures)
// after: your model owns the dictionary
let layers = Dictionary<int, CellGrid2D<Tile>>()
let layer index paint =
let grid =
match Dictionary.tryGetValue index layers with
| ValueSome grid -> grid
| ValueNone ->
let grid = CellGrid2D.create 50 30 cellSize origin
layers.[index] <- grid
grid
Layout.run paint grid
layer 0 (fun s -> s |> Layout.fill 0 0 50 30 Terrain) |> ignore
layer 1 (fun s -> s |> Layout.fill 2 2 10 8 Structures) |> ignore
For independent per-cell attributes (terrain under items), prefer one
grid whose tile is a record:
Tile = { Ground: GroundKind; Prop: PropKind voption }. One array
walk beats N parallel grids.
5. Replace the stamp libraries with Flow styles
Flow styles replace the stamp libraries' vocabulary. The retired pages stay as pattern references.
Retired |
Replacement |
|---|---|
|
|
|
A row of columns with stepped |
|
|
|
|
|
|
|
|
|
|
|
Stepped-height |
|
The worked level above |
|
Height-carrying tiles. A window is a border cell with a short |
A platformer level, before and after:
// before: Platformer stamps
level
|> Layout.run (fun s ->
s
|> Platformer.platform 4 6 8 2 Brick
|> Platformer.stairs 10 0 4 5 Stone
|> Platformer.box 20 0 6 4 Crate)
// after: Flow styles over height-carrying tiles
let level =
Flow.row FlowOpts.Default [
Stamp.box 8 2 [ Flow.fill { Kind = Brick; Height = 1 } ]
Stamp.box 4 5 [ Flow.texture(fun x _ -> { Kind = Step; Height = x + 1 }) ]
Stamp.box 6 4 [ Flow.fill { Kind = Crate; Height = 1 } ]
]
6. State a span and read the occupancy
A cell can cover more than its own cell. InstanceSpan states how many
cells one instance covers, and a build derives an Occupancy from the
painted grid, so a query reads the instance that owns a cell instead of
the empty neighbours next to it.
Two fields on Doc.Surface carry it, and the record requires both:
let spanOf (cell: BlockCell) = cell.Span
let withSpan (cell: BlockCell) (span: InstanceSpan) = { cell with Span = span }
let surface: Doc.Surface<BlockCell> = {
Words = words
Kernels = kernels
Elements = elements
Span = ValueSome spanOf // absent: every cell covers one cell
WithSpan = ValueSome withSpan // absent: a statement cannot size one
}
A document then sizes one instance with set:
|
XML spells the same statement <set x="3" y="9" cell="slab" spanX="16" spanZ="6" />.
No other statement takes a span: they paint every cell of their box.
DocFlow.buildLayers returns the occupancy with each layer, and
DocFlow.build refuses a document that places a spanning word — one grid
cannot report the occupancy the map needs.
for layer in layers do
context.RenderInstanced(buffer, layer.Grid, layer.Occupancy)
Render with the occupancy form. Give the context a rectangle transform so one model scales over the cells it covers; 3D from 2D shows the full transform.
Occupancy.owner answers which instance owns a cell, rectOf gives its
rectangle, and iterInWindow enumerates the anchors a window touches.
Stack.feet gives each layer the height the layers below it reach, so a
decoration stands on a plate instead of replacing it.
7. Migration checklist
-
Swap
HexGrid.createforCellGrid2D.createHexwith aHexSpec.set/get/clear/iter/getWorldPoscalls rename toCellGrid2D.*with the same arguments. -
Replace
HexLayoutpipelines withLayoutover hex storage, or move the level into a Flow document. -
Give 3D content a
Column-style tile (KindplusHeight). Rebuild the level as a Flow document overCellGrid2D. - Move
getWorldPosconsumers to the footprint-plus-height formula. -
Replace renderer calls with your own
iter+drawInstancedloop (scaled unit blocks per column). For the volume-culled instanced renderers, follow volume-culled instanced rendering. -
Replace 3D pathfinding with footprint pathfinding over
Grid2DSpatial/Hex2DSpatial. - Replace layered grids with a dictionary you own, or fold the layers into the tile as fields.
- Read Code-First Maps. Hex and 3D-as-heightmap levels now author with the same DSL.
-
Add
SpanandWithSpanto everyDoc.Surfaceyou construct — bothValueNonekeeps the map as it is — and read each layer'sOccupancywhen a cell can cover more than one cell. See state a span and read the occupancy.
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
val float32: value: 'T -> float32 (requires member op_Explicit)
--------------------
type float32 = System.Single
--------------------
type float32<'Measure> = float32
module ValueOption from Microsoft.FSharp.Core
--------------------
type ValueOption<'T> = | ValueNone | ValueSome of 'T static member Some: value: 'T -> 'T voption static member op_Implicit: value: 'T -> 'T voption member IsNone: bool member IsSome: bool member Value: 'T static member None: 'T voption
Mibo