Skip to content

How to Build: Ribbon Projection

This is where the game gets its distinctive look. Instead of flat terrain stretching out to the horizon, the hex grid wraps around a 3D helix -- terrain that spirals around a central axis like a ribbon wound around an invisible tower. I always loved this visual since we first prototyped it in beta, and the math behind it is surprisingly elegant.

The idea: after the WFC pipeline generates flat hex-grid vertex data, we project every vertex position from 2D space onto a curved 3D surface. The flat grid becomes a spiraling path. The player walks along the ribbon, and the camera orbits around the helix. It sells the fantasy of descending deeper into the earth.

The parametric model

The ribbon projection maps a flat 2D plane onto a helix using two parametric coordinates:

  • t -- position along the helix arc (how far around the spiral the vertex sits)
  • w -- position across the ribbon width (left-to-right on the path)

Plus a height displacement that pushes the vertex away from the helix surface (for terrain elevation).

Flat hex grid         Parametric UV         3D helix surface
  (x, z)       -->      (t, w)       -->     (worldX, worldY, worldZ)

Let's unpack the math. A point on a helix at parameter t with ribbon width offset w sits at:

x = (R + w) * cos(t)
y = (P / 2pi) * t          -- vertical rise
z = (R + w) * sin(t)

Where R is the helix radius, P is the pitch (vertical rise per full rotation), and w ranges from -W/2 to +W/2 (half the ribbon width in each direction). The displacement pushes the vertex outward along the surface normal for terrain height.

The RibbonConfig shape

typescript
interface RibbonConfig {
  R: number                  // helix radius
  W: number                  // ribbon width
  P: number                  // pitch (vertical rise per full rotation)
  segmentArc: number         // angular span in radians (e.g., 2*pi = one full rotation)
  segmentStartAngle: number  // where this segment begins on the helix
  biomeId: string            // which biome to use
}

Notice segmentArc and segmentStartAngle -- these let you define a segment of the helix rather than the whole thing. A level might occupy one full rotation (2 * pi) starting at angle 0, while the next level picks up where the previous one left off.

The key functions

projectToRibbon

typescript
function projectToRibbon(
  t: number,              // arc position
  w: number,              // width position
  displacement: number,   // height above the ribbon surface
  config: RibbonConfig
): { x: number, y: number, z: number }

This is the heart of the system. It takes parametric coordinates and a displacement value, and returns a 3D world position on the helix surface. The implementation follows the parametric helix equations above, with the displacement applied along the surface normal.

hexToUV

typescript
function hexToUV(
  hexWorldX: number,
  hexWorldZ: number,
  arcLength: number,
  W: number
): { t: number, w: number }

Converts flat hex world coordinates into ribbon UV parameters. The hex X position maps to t (normalized against the arc length), and the hex Z position maps to w (normalized against the ribbon width). This is what makes the flat-to-curved mapping work -- every vertex knows where it belongs on the ribbon.

segmentArcLength

typescript
function segmentArcLength(config: RibbonConfig): number

Computes the total arc length for the ribbon segment. Used both by hexToUV for coordinate normalization and by the camera system for framing the level.

Integration with build-dirty-chunks

This is where it gets interesting. The ribbon projection does not run as a separate pass. It hooks directly into the existing build-dirty-chunks system.

The RibbonState world trait controls whether projection is active:

typescript
const RibbonState = trait({
  active: false,
  config: null as RibbonConfig | null,
})

When RibbonState.active is true, build-dirty-chunks reads each vertex's flat position from the vertex registry, converts it to ribbon UV via hexToUV, then projects it onto the helix via projectToRibbon. The resulting 3D position goes into the BufferGeometry that gets uploaded to the GPU.

When active is false, vertices are used as-is (flat hex layout). This means you can toggle ribbon projection on and off without regenerating any terrain data -- the same vertex registry serves both modes.

The existing beta code already has this check in build-dirty-chunks. As long as you port the ribbon math functions and the RibbonState trait, the integration should work with WFC-generated data because the vertex format (position, splat weights, texture indices, tint) is identical regardless of the data source.

Configuring RibbonState per level tier

When the level manager starts a new level, it sets RibbonState with parameters appropriate for the depth tier. Deeper tiers might have a tighter spiral (smaller R), narrower ribbon (smaller W), or steeper pitch (larger P):

typescript
// Example: Hollows tier
world.set(RibbonState, {
  active: true,
  config: {
    R: 80,          // generous radius for the early game
    W: 60,          // wide ribbon so the player has room to explore
    P: 40,          // gentle slope
    segmentArc: Math.PI * 2,
    segmentStartAngle: 0,
    biomeId: 'hollows',
  },
})

You could also make the ribbon tighter on death runs to increase tension, or widen it for boss arenas. The config is set per level, so you have full control.

Files to port from beta

FilePurpose
terrain/ribbon/types.tsRibbonConfig interface
terrain/ribbon/ribbon-math.tsprojectToRibbon, hexToUV, segmentArcLength
systems/build-dirty-chunks.tsAlready has the RibbonState.active check (verify it works with WFC data)

The math is proven and has been running in beta without issues. The main thing to verify is that hexToUV produces sensible UV values for the WFC grid dimensions -- the flat hex coordinate ranges need to map cleanly onto the arc length and ribbon width.

What comes next

With ribbon projection working, the terrain pipeline is complete: WFC generates zones, chunk integration writes vertices, biome blending adds visual richness, and the ribbon wraps it all around a helix. The next phase shifts focus from terrain to gameplay -- spawning enemies along the road, building the combat system, and giving the player something to fight.