Appearance
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
buildDirtyChunkssystem andBufferGeometryconstruction
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 = 0is the start of the segment,t = 1is the end. - w -- position across the ribbon width, normalized to [-0.5, 0.5].
w = 0is the ribbon centerline,w = -0.5is the inner edge,w = 0.5is 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 * NzWhere:
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:
- Compute
thetafromtand the segment's start angle and arc span - Find the ribbon centerline point on the helix:
(R*cos(theta), -(theta/2pi)*P, R*sin(theta)) - Compute the radial outward vector (perpendicular to the helix axis in the XZ plane):
(cos(theta), 0, sin(theta)) - Compute the tangent vector along the helix and the surface normal via cross product
- Offset from centerline by
w * Walong the radial direction - Offset by
displacementalong 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, centeredHex 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): numberComputes 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) * segmentArcThis 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 attributeThe 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.activeon/off just requires re-tagging all chunks asIsDirty - 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:
- Store the projected normal per vertex during projection and write it as a normal attribute
- 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 gapsIf 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.