Skip to content

Ribbon Projection: The Helical Spiral

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. The player walks a spiral path that descends into the earth, and the camera orbits the helix to reveal the full structure.

The elegant part: the projection is purely visual. The Wave Function Collapse (WFC) solver, noise pass, biome blending, and all game logic operate on flat 2D hex coordinates. Projection transforms vertex positions at render time without changing the underlying data. Movement, combat, spawning -- everything works in flat space. Only the renderer sees the ribbon.

Goal

Integrate the ribbon projection system so that flat hex-grid terrain wraps onto a 3D helical surface. After this step, the same WFC-generated level that previously rendered as a flat grid now spirals through space as a ribbon, toggleable on and off without regenerating any terrain data.

Prerequisites

  • 06 Chunk Integration -- vertex data flows through the render pipeline
  • 09 Level Lifecycle -- levels generate and transition correctly
  • Understanding of the buildDirtyChunks system and BufferGeometry construction

Key Concepts

Why project after generating flat

This is a fundamental architecture decision. The WFC pipeline generates flat hex grids. The ribbon system projects those grids onto a curved surface. Why not generate directly on the helix?

Flat generation + visual projection
──────────────────────────────────────────────────────

  WFC solver        noise pass       game logic       renderer
  (flat grid)  -->  (flat grid)  --> (flat coords) --> (projected 3D)
       |                |                |                  |
       v                v                v                  v
  2D grid math     2D sampling     2D distances      3D helix math
  (simple)         (simple)        (simple)          (one place)

Every system except the renderer works in simple 2D. Pathfinding uses flat distances. Enemy spawning uses flat grid positions. The camera's orbit center is in flat space (projected to 3D only for final positioning). The complexity of helical math is contained in a single function: projectToRibbon.

If we generated directly on the helix, every system would need to understand curved-space distances, geodesics, and the fact that "left" and "right" change meaning as you spiral around. That is a recipe for bugs in every system that touches spatial reasoning.

The parametric helix model

A ribbon on a helix is defined by two parameters:

  • t -- progress along the helix arc, normalized to [0, 1]. t = 0 is the start of the segment, t = 1 is the end.
  • w -- position across the ribbon width, normalized to [-0.5, 0.5]. w = 0 is the ribbon centerline, w = -0.5 is the inner edge, w = 0.5 is the outer edge.

Plus a displacement value that pushes the vertex away from the ribbon surface (terrain height).

Cross-section of the ribbon at a given t:

              displacement (terrain height)
                    |
                    v
    ────────────────*────────────────  ribbon surface
    |                               |
  w = -0.5                        w = 0.5
  (inner edge)                   (outer edge)
                w = 0
             (centerline)
                    |
                    |  R (helix radius)
                    |
                    * helix axis (center of spiral)

The helix equation maps these parameters to 3D world coordinates:

theta = segmentStartAngle + t * segmentArc

x = R * cos(theta) + w * W * cos(theta) + displacement * Nx
y = (theta / 2pi) * P + displacement * Ny
z = R * sin(theta) + w * W * sin(theta) + displacement * Nz

Where:

  • R = helix radius (distance from axis to ribbon centerline)
  • W = ribbon width (total playable cross-section)
  • P = pitch (vertical drop per full 2pi revolution)
  • N = surface normal (computed from the cross product of the tangent and radial vectors)

The Y term scales linearly with theta, so the helix rises (or descends) at a rate determined by the pitch P. The sign of P controls the direction -- positive pitch ascends, negative descends.

RibbonConfig

The configuration for a ribbon segment:

typescript
interface RibbonConfig {
  R: number;                 // helix radius -- axis to centerline
  W: number;                 // ribbon width -- playable cross-section
  P: number;                 // pitch -- vertical rise per full 2pi revolution
  segmentArc: number;        // angular span in radians (2pi = one full turn)
  segmentStartAngle: number; // where this segment begins on the helix
  biomeId: string;           // which biome preset for noise sampling
}

segmentArc and segmentStartAngle allow a level to occupy a portion of the helix. A level might span one full revolution (segmentArc = 2 * Math.PI) starting at angle 0. The next level could pick up where the previous one left off, creating a continuous descent.

The three key functions

The ribbon math lives in src/ribbon/ribbon-math.ts and consists of three functions:

projectToRibbon

typescript
function projectToRibbon(
  t: number,              // progress along ribbon [0, 1]
  w: number,              // position across width [-0.5, 0.5]
  displacement: number,   // height above ribbon surface
  config: RibbonConfig,
): { x: number; y: number; z: number }

This is the heart of the system. It converts parametric UV coordinates into a 3D world position on the helix surface. The implementation:

  1. Compute theta from t and the segment's start angle and arc span
  2. Find the ribbon centerline point on the helix: (R*cos(theta), -(theta/2pi)*P, R*sin(theta))
  3. Compute the radial outward vector (perpendicular to the helix axis in the XZ plane): (cos(theta), 0, sin(theta))
  4. Compute the tangent vector along the helix and the surface normal via cross product
  5. Offset from centerline by w * W along the radial direction
  6. Offset by displacement along the surface normal

The surface normal computation ensures that terrain height pushes vertices away from the ribbon surface rather than straight up in world space. On a curved ribbon, "up" changes direction as you go around the spiral.

hexToUV

typescript
function hexToUV(
  hexWorldX: number,       // flat hex x position
  hexWorldZ: number,       // flat hex z position
  arcLength: number,       // total arc length of the segment
  W: number,               // ribbon width
): { t: number; w: number }

Converts flat hex world coordinates to ribbon parametric UV. The mapping is straightforward:

t = hexWorldZ / arcLength    // Z maps to progress along the ribbon
w = hexWorldX / W - 0.5     // X maps to position across the width, centered

Hex Z (the "forward" direction in flat space) becomes progress along the helix. Hex X (the "sideways" direction) becomes position across the ribbon width. The - 0.5 centers the width so the ribbon centerline corresponds to w = 0.

segmentArcLength

typescript
function segmentArcLength(config: RibbonConfig): number

Computes the total arc length of the ribbon segment in world units. The arc length of a helix segment is:

arcLength = sqrt(R^2 + (P / 2pi)^2) * segmentArc

This accounts for both the circular motion (radius R) and the vertical rise (pitch P). The arc length is used by hexToUV to normalize the Z coordinate and by the camera system for framing.

How flat coordinates map to the ribbon

Here is the full conversion chain for a single vertex:

Flat hex position (x, z)
         |
         v
hexToUV(x, z, arcLength, W)  -->  (t, w)
         |
         v
vertex height from noise pass  -->  displacement
         |
         v
projectToRibbon(t, w, displacement, config)  -->  (worldX, worldY, worldZ)

And visually, the mapping from flat to curved:

Flat hex grid:                    Ribbon on helix:

  z (progress)                         /---\
  ^                                   /  .  \   t = 1.0 (top)
  |  ___   ___                       |  .    |
  | / . \ / . \                      |   .   |
  |/ . . X . . \                      \ .   /
  |\ . . / \ . /                       \---/
  | \___/ \___/                        / . \
  |  ___   ___                        |  .  |   t = 0.5 (halfway around)
  | / . \ / . \                       |   . |
  |/ . . X . . \                       \ . /
  |\ . . / \ . /                        \-/
  | \___/ \___/                         / \
  +──────────────> x (width)           |   |    t = 0.0 (bottom)
                                        \_/

  x maps to w (across)
  z maps to t (along)

Integration with buildDirtyChunks

The ribbon projection does not run as a separate pass or system. It hooks directly into the existing buildDirtyChunks system via the RibbonState world trait:

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

When buildDirtyChunks processes an IsDirty chunk, it checks RibbonState.active:

if RibbonState.active:
  for each vertex in registry:
    (t, w) = hexToUV(vertex.x, vertex.z, arcLength, config.W)
    displacement = vertex.height
    (worldX, worldY, worldZ) = projectToRibbon(t, w, displacement, config)
    --> write (worldX, worldY, worldZ) into position attribute

else:
  for each vertex in registry:
    --> write (vertex.x, vertex.height, vertex.z) into position attribute

The vertex data in the registry stays flat regardless of ribbon state. Projection happens during geometry construction, producing 3D positions that go into the BufferGeometry position attribute. This means:

  • Toggling RibbonState.active on/off just requires re-tagging all chunks as IsDirty
  • No vertex data regeneration needed
  • The flat and ribbon views share the exact same vertex registry data

Normals on the ribbon

After projecting vertex positions onto the helix surface, normals need recomputation. In flat mode, normals point straight up (0, 1, 0) with slight tilting from terrain height variation. On the ribbon, "up" is the surface normal of the helix at each vertex's position -- it points radially outward from the helix axis, tilted by the tangent vector.

The projectToRibbon function already computes the surface normal as part of its displacement calculation. To get correct normals in the BufferGeometry, you have two options:

  1. Store the projected normal per vertex during projection and write it as a normal attribute
  2. Call geometry.computeVertexNormals() after setting all positions -- this derives normals from the triangle geometry

Option 2 is simpler and works well for terrain. The existing buildDirtyChunks system already calls computeVertexNormals() after constructing the geometry, so this should work without modification.

Manifest integration

A level's manifest can include a ribbon key that automatically activates ribbon projection on load:

json
{
  "chunks": ["0_0", "0_1", "1_0", "1_1"],
  "biomeMap": { "0_0": "grassland", "0_1": "grassland" },
  "ribbon": {
    "R": 80,
    "W": 60,
    "P": 40,
    "segmentArc": 6.283,
    "segmentStartAngle": 0,
    "biomeId": "grassland"
  }
}

When initMapManifest loads a manifest with a ribbon key, it sets RibbonState.active = true and stores the config. For procedurally generated levels, the level manager sets RibbonState directly based on the tier configuration.

Implementation Steps

Step 1: Verify the ribbon math module exists

The ribbon math is already implemented in src/ribbon/ribbon-math.ts. Verify that projectToRibbon, hexToUV, and segmentArcLength are exported and match the signatures described above. The implementation uses proper surface normal computation via cross product of the tangent and radial vectors.

Step 2: Add RibbonState to world traits

If RibbonState is not already initialized on the world, add it during world setup:

typescript
// In world.ts or startup.ts
world.add(RibbonState);

The trait starts with active: false and config: null, so ribbon projection is off by default.

Step 3: Wire ribbon projection into buildDirtyChunks

In src/systems/build-dirty-chunks.ts, add a check for RibbonState.active during vertex position writing. This is the critical integration point:

typescript
function buildDirtyChunks(world: World): void {
  const ribbonState = world.get(RibbonState);
  let arcLength = 0;

  if (ribbonState.active && ribbonState.config) {
    arcLength = segmentArcLength(ribbonState.config);
  }

  world.query(ChunkCoord, ChunkMesh, IsDirty).updateEach(([coord, mesh]) => {
    const key = `${coord.q}_${coord.r}`;
    const vertices = vertexRegistry.get(key);
    if (!vertices) return;

    const positions: number[] = [];
    // ... other attribute arrays

    for (const v of vertices) {
      if (ribbonState.active && ribbonState.config) {
        // Project flat position onto helix
        const { t, w } = hexToUV(v.x, v.z, arcLength, ribbonState.config.W);
        const projected = projectToRibbon(t, w, v.height, ribbonState.config);
        positions.push(projected.x, projected.y, projected.z);
      } else {
        // Flat mode: use position directly
        positions.push(v.x, v.height, v.z);
      }

      // Weights, texture indices, tint are the same in both modes
      // ...
    }

    // Build BufferGeometry, compute normals, assign material
    // ...

    entity.remove(IsDirty);
  });
}

The key insight: only the position attribute changes between flat and ribbon modes. Splat weights, texture indices, and tint are identical. The shader does not know or care whether the geometry is flat or curved.

Step 4: Set RibbonState during level generation

In startLevel, set RibbonState based on the tier configuration:

typescript
function startLevel(world: World, levelNumber: number): void {
  const tier = getTierConfig(levelNumber);

  // ... generate and populate terrain ...

  // Activate ribbon projection
  world.set(RibbonState, {
    active: true,
    config: {
      R: 80,                          // helix radius
      W: 60,                          // ribbon width
      P: 40,                          // pitch
      segmentArc: Math.PI * 2,        // one full revolution
      segmentStartAngle: 0,
      biomeId: tier.biomeId,
    },
  });

  // Re-tag all chunks as dirty so they rebuild with ribbon projection
  world.query(ChunkCoord).forEach((entity) => {
    if (!entity.has(IsDirty)) {
      entity.add(IsDirty);
    }
  });
}

Step 5: Implement ribbon toggle

Add a debug control that toggles ribbon projection on and off:

typescript
function toggleRibbon(world: World): void {
  const state = world.get(RibbonState);
  state.active = !state.active;

  // Re-tag all chunks as dirty
  world.query(ChunkCoord).forEach((entity) => {
    if (!entity.has(IsDirty)) {
      entity.add(IsDirty);
    }
  });
}

This is useful for debugging: toggle off to see the flat layout, toggle on to see the spiral. No vertex data regeneration happens -- the same registry data is simply interpreted differently.

Step 6: Adjust camera for ribbon mode

The orbit camera needs to account for ribbon geometry. In flat mode, the terrain sits on the XZ plane and the camera orbits around a point on that plane. In ribbon mode, the terrain wraps around a vertical axis and the camera should orbit around the helix center:

typescript
// In updateCamera, adjust the orbit center for ribbon mode
const ribbonState = world.get(RibbonState);
if (ribbonState.active && ribbonState.config) {
  // Orbit around the helix axis instead of the player
  // The helix axis is at world origin (0, centerY, 0)
  // centerY = midpoint of the helix vertical span
  const midAngle = ribbonState.config.segmentStartAngle +
    ribbonState.config.segmentArc / 2;
  const centerY = -(midAngle / (2 * Math.PI)) * ribbonState.config.P;
  // Use (0, centerY, 0) as orbit center
}

The initial camera radius should be large enough to see the full helix. A good starting value is R * 2.5 where R is the helix radius.

Step 7: Validate UV mapping

The most common issue with ribbon projection is incorrect UV mapping -- the flat hex coordinates do not map cleanly onto the arc length and ribbon width, causing the terrain to stretch, compress, or wrap incorrectly.

To validate, check these conditions:

For a level that is chunksLong chunks along Z:
  - The total flat Z span should approximately equal segmentArcLength(config)
  - If it does not, the terrain will stretch or compress along the ribbon

For a level that is chunksWide chunks along X:
  - The total flat X span should approximately equal config.W
  - If it does not, the terrain will spill off the ribbon edges or leave gaps

If the proportions do not match, adjust either the level dimensions or the ribbon config so that the flat grid maps cleanly onto the ribbon surface.

Step 8: Tier-specific ribbon configurations

Different depth tiers can have different ribbon shapes:

typescript
const TIER_RIBBON_CONFIGS: Partial<RibbonConfig>[] = [
  { R: 80, W: 60, P: 40 },    // Tier 0: generous radius, wide ribbon, gentle slope
  { R: 60, W: 50, P: 50 },    // Tier 1: tighter spiral, narrower, steeper
  { R: 40, W: 40, P: 60 },    // Tier 2: claustrophobic, steep descent
];

Deeper tiers with smaller radii and steeper pitches create a sense of increasing danger and confinement. The narrower ribbon means less room to maneuver. The steeper pitch means faster vertical descent.

Visual Checkpoint

Load a level with ribbon projection active:

  • The terrain forms a visible 3D spiral. Instead of flat hex chunks on the XZ plane, the terrain wraps around and ascends/descends in a helix shape.
  • The road is visible winding up the helix. Track the road zone visually -- it should spiral around the central axis as a continuous path.
  • Camera orbiting reveals the full structure. Rotate the camera around the helix and verify that the terrain wraps correctly -- no gaps, no overlapping geometry, no inside-out faces.
  • Toggle ribbon off via debug panel. The terrain should snap back to a flat layout. All the same chunks, same textures, same weights -- just flat instead of curved.
  • Toggle ribbon on again. The spiral reappears. No regeneration, no loading -- just a geometry rebuild with projected positions.
  • Normals look correct. Lighting on the ribbon surface should vary as expected -- the inner curve of the helix is lit differently from the outer curve. If lighting looks flat or wrong, check that computeVertexNormals() runs after projection.
  • The hex grid pattern holds. Zoom in on the ribbon surface. Individual hex tiles should still be visible and correctly shaped, not stretched or distorted. Some distortion at the ribbon edges (where curvature is strongest) is acceptable, but the center should look clean.

If the terrain appears inside-out (normals pointing inward), check the winding order of the projected triangles. The projection may flip the triangle winding if the ribbon width offset changes the handedness. If the terrain clusters at one point instead of spreading along the helix, check the hexToUV mapping -- the arc length normalization may be wrong.

What's Next

With ribbon projection working, the terrain pipeline is complete. The full path from seed to spiral is:

seed --> WFC solver --> zone pass --> tile pass --> noise pass
  --> chunk integration --> biome blending --> ribbon projection --> Graphics Processing Unit (GPU)

The next phase shifts focus from terrain to gameplay: spawning enemies along the road, building the auto-battle combat system, and giving the player something to fight as they descend the spiral.