fuzzyworld

nordic-skating / commit

feat(lake): long islands of reeds, with water both sides that is one again beyond

26ace8f53dd305ba000628a0c9c49549d2f39839 · Pavel Safronov · 2026-10-10 09:23:10 -0700 · parents 6e58e276236e

Claude Code usage: this commit ~68.8K out / ~18.4M cached · cost unknown (no per-model trailer) · session 1 tally 1.1K in / 978.6K out / 157.4M cached · usage by commit and session

feat(lake): long islands of reeds, with water both sides that is one again beyond

A low bank of reeds, 180 to 480 m long and up to 32 m across, standing
in the middle of a channel. The channel is wider by the island's width
where it stands, so the water either side is as wide as the channel
would have been. About one every 5 km; none near a fork or in a pass,
and no islets beside one.

The skater has the water on whichever side they are on. A touch of
steering on the way up chooses the side, as at a fork; with none, the
island has a side it prefers, and the autopilot goes down the middle of
the water on that side.

Usage: 1.1K in / 978.6K out / 157.4M cached (session tally)
rtk:   1.7M saved, 25.3% over 2685 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 src/render/shores.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++--------
 src/skater.ts        | 32 +++++++++++++++++++++++++++++--
 src/world/lake.ts    | 38 +++++++++++++++++++++++++++++++++++-
 test/lake.test.ts    | 23 ++++++++++++++++++++++
 test/skater.test.ts  | 39 ++++++++++++++++++++++++++++++++++++-
 5 files changed, 174 insertions(+), 12 deletions(-)

diff --git a/src/render/shores.ts b/src/render/shores.ts
index 64be6fe..12e1e0f 100644
--- a/src/render/shores.ts
+++ b/src/render/shores.ts
@@ -23,6 +23,8 @@ const TREES_PER_ISLAND = 50
 /** Reeds on an islet for each square metre of it, and the most on one. */
 const REEDS_PER_M2 = 2.5
 const MOST_REEDS = 260
+/** Reeds on each stretch of a long island. */
+const REEDS_PER_ISLAND = 1400
 
 const snow = new THREE.MeshStandardMaterial({ color: 0xe8eef4, roughness: 0.9 })
 const spruce = new THREE.MeshStandardMaterial({ color: 0x1f3b2c, roughness: 1, flatShading: true })
@@ -49,6 +51,8 @@ interface Shoreline {
   /** The island's two shores; the same number where there is no island. */
   islandLeft: number
   islandRight: number
+  /** Whether the island is a low bank of reeds, as one standing in a channel is, and not wooded land. */
+  reeds?: boolean
 }
 
 function shoreline(channel: Channel, s: number): Shoreline {
@@ -57,6 +61,10 @@ function shoreline(channel: Channel, s: number): Shoreline {
   const last = channels[channels.length - 1]!
   const left = first.centre(s) - first.halfWidth(s)
   const right = last.centre(s) + last.halfWidth(s)
+  // The land in the middle is the island between a fork's two channels, or the island
+  // standing in one channel.
+  const within = channels.length === 1 ? first.island(s) : 0
+  if (within > 0) return { left, right, islandLeft: first.centre(s) - within, islandRight: first.centre(s) + within, reeds: true }
   const islandLeft = first.centre(s) + first.halfWidth(s)
   const islandRight = last.centre(s) - last.halfWidth(s)
   // One channel, or two that have not yet drawn apart: no island, and its two shores
@@ -131,10 +139,10 @@ function meshHeight(s0: number, s: number, columns: readonly number[], at: numbe
 
 /** How high the island between two channels is at `s`, a fraction `t` of the way across it. */
 function islandHeight(lake: Lake, channel: Channel, s: number, t: number): number {
-  const { islandLeft, islandRight } = shoreline(channel, s)
+  const { islandLeft, islandRight, reeds } = shoreline(channel, s)
   // An island rises from both its shores, so its height is that of land as far inland
-  // as its nearer shore.
-  return lake.bankHeight(1, s, Math.min(t, 1 - t) * (islandRight - islandLeft))
+  // as its nearer shore. A bank of reeds is a quarter as high as wooded land would be.
+  return lake.bankHeight(1, s, Math.min(t, 1 - t) * (islandRight - islandLeft)) * (reeds ? 0.25 : 1)
 }
 
 function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: number, s0: number): THREE.InstancedMesh {
@@ -162,16 +170,46 @@ function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: nu
   }
   for (let i = 0; i < TREES_PER_ISLAND; i++) {
     const s = s0 + random() * CHUNK
-    const { islandLeft, islandRight } = shoreline(channel, s)
+    const { islandLeft, islandRight, reeds } = shoreline(channel, s)
     const width = islandRight - islandLeft
     const x = islandLeft + 2 + random() * (width - 4)
-    const ground = width > 8 ? meshHeight(s0, s, ACROSS, (x - islandLeft) / width, (rowS, t) => islandHeight(lake, channel, rowS, t)) : 0
-    // No island here, or one too narrow to stand a tree on: a tree of no size.
-    plant(x, s, ground, width > 8 ? 0.7 + random() * 0.9 : 0)
+    const wooded = width > 8 && !reeds
+    const ground = wooded ? meshHeight(s0, s, ACROSS, (x - islandLeft) / width, (rowS, t) => islandHeight(lake, channel, rowS, t)) : 0
+    // No island here, one too narrow to stand a tree on, or one of reeds: a tree of no size.
+    plant(x, s, ground, wooded ? 0.7 + random() * 0.9 : 0)
   }
   return mesh
 }
 
+/** The reeds of one stretch of an island that has them, if there is one in this stretch. */
+function islandReeds(lake: Lake, channel: Channel, seed: number, index: number, x0: number, s0: number): THREE.Object3D[] {
+  let any = false
+  for (let s = s0; s <= s0 + CHUNK && !any; s += STEP) any = shoreline(channel, s).reeds === true
+  if (!any) return []
+  const random = mulberry32(seed ^ Math.imul(index, 0xc2b2ae35))
+  const stand = new THREE.InstancedMesh(reed, straw, REEDS_PER_ISLAND)
+  const at = new THREE.Object3D()
+  for (let n = 0; n < REEDS_PER_ISLAND; n++) {
+    const s = s0 + random() * CHUNK
+    const { islandLeft, islandRight, reeds } = shoreline(channel, s)
+    const width = islandRight - islandLeft
+    // Across all of it but its very edges.
+    const t = 0.06 + 0.88 * random()
+    const here = reeds && width > 1.5
+    at.position.set(
+      islandLeft + t * width - x0,
+      here ? meshHeight(s0, s, ACROSS, t, (rowS, across) => islandHeight(lake, channel, rowS, across)) - 0.05 : 0,
+      -(s - s0),
+    )
+    at.rotation.set((random() - 0.5) * 0.35, random() * Math.PI, (random() - 0.5) * 0.35)
+    const tall = here ? 0.9 + random() * 1.3 : 0
+    at.scale.set(tall > 0 ? 1 : 0, tall, tall > 0 ? 1 : 0)
+    at.updateMatrix()
+    stand.setMatrixAt(n, at.matrix)
+  }
+  return [stand]
+}
+
 /** The islets of one stretch: their humps, and the reeds on them. */
 function islets(channel: Channel, x0: number, s0: number): THREE.Object3D[] {
   const found = channel.islets(s0, s0 + CHUNK)
@@ -297,7 +335,7 @@ export class Shores {
       const t = ACROSS[col]!
       return [islandLeft + t * (islandRight - islandLeft), islandHeight(lake, channel, s, t)]
     }), snow))
-    group.add(trees(lake, channel, this.seed, index, x0, s0), ...islets(channel, x0, s0))
+    group.add(trees(lake, channel, this.seed, index, x0, s0), ...islets(channel, x0, s0), ...islandReeds(lake, channel, this.seed, index, x0, s0))
     this.group.add(group)
     return { x: x0, s: s0, group }
   }
diff --git a/src/skater.ts b/src/skater.ts
index b881eef..0869b08 100644
--- a/src/skater.ts
+++ b/src/skater.ts
@@ -73,6 +73,9 @@ const LOOK_AHEAD = 18
 const CARVE = 6
 /** From this far before a fork, metres, steering one way is asking to go that way at it. */
 const FORK_ASK = 200
+/** And from this far before an island, likewise; nearer than this the side is settled. */
+const ISLAND_ASK = 120
+const ISLAND_NEAR = 40
 
 /** How long stride number `index` lasts, seconds: the same every time it is asked. */
 export function strideSeconds(index: number): number {
@@ -106,6 +109,11 @@ export interface Skater {
    * have not: the way they have asked to go at it.
    */
   wish: number
+  /**
+   * Which side of an island the skater is passing, or is about to: -1 left, 1 right, and
+   * 0 when there is no island near.
+   */
+  side: number
   /** Strides taken, with the fraction being how far through the current one. */
   stride: number
   /** Seconds left in the water after falling through the ice; 0 while skating. */
@@ -117,7 +125,7 @@ export function createSkater(lake: Lake, s = 0): Skater {
   const channel = channelAt(lake, s)
   return {
     x: channel.centre(s), s, channel, heading: Math.atan(channel.slope(s)), turn: 0, speed: SPEED,
-    hold: 0, idle: Infinity, wish: 0, stride: 0, under: 0,
+    hold: 0, idle: Infinity, wish: 0, side: 0, stride: 0, under: 0,
   }
 }
 
@@ -156,6 +164,12 @@ function room(k: Skater, s: number): [number, number] {
   const own = reach(k.channel, s)
   let left = middle - own
   let right = middle + own
+  // An island in the channel: the skater has the water on the side of it they are on.
+  const island = k.channel.island(s)
+  if (island > 0) {
+    if ((k.side || Math.sign(k.x - middle) || 1) > 0) left = Math.max(left, middle + island + SHORE_MARGIN)
+    else right = Math.min(right, middle - island - SHORE_MARGIN)
+  }
   // While the other channel of a fork is still alongside, its width is theirs as well.
   const other = alongside(k)
   if (other) {
@@ -198,6 +212,10 @@ const wrap = (angle: number): number => angle - 2 * Math.PI * Math.round(angle /
  * The skater may point any way at all, back up the lake included. Nothing here assumes
  * otherwise; left alone, the autopilot turns them down the lake again.
  *
+ * An island is passed on the side the skater steered towards on the way up to it, or is
+ * already well over to, or else the side the island prefers; alongside it, they are on
+ * the side they are on.
+ *
  * At a fork, a skater who has steered one way on the way up to it goes that way, and one
  * who has not goes the way the fork prefers. A touch is enough: the steering is taken as
  * asking, and the autopilot does the rest. Until the island between the two channels
@@ -218,6 +236,14 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
   }
   const { channel } = k
   const along = Math.atan(channel.slope(k.s))
+  // Which side of an island: alongside one, the side the skater is on; coming up to
+  // one, the side they asked for or are over to, or the island's own choice.
+  const off = k.x - channel.centre(k.s)
+  if (channel.island(k.s) > 0) k.side = Math.sign(off) || k.side || 1
+  else if (channel.island(k.s + ISLAND_ASK) > 0 || channel.island(k.s + ISLAND_NEAR) > 0) {
+    if (Math.abs(steer) > 0.05) k.side = Math.sign(steer)
+    else if (k.side === 0 && channel.island(k.s + ISLAND_NEAR) > 0) k.side = Math.abs(off) > 3 ? Math.sign(off) : channel.islandSide(k.s + ISLAND_NEAR)
+  } else k.side = 0
   const [left, right] = room(k, k.s)
 
   // What turn is wanted: the steering's, or the autopilot's.
@@ -243,7 +269,9 @@ export function stepSkater(k: Skater, lake: Lake, steer: number, dt: number, rul
     const [farLeft, farRight] = room(k, ahead)
     // Aimed clear of the edges, or down the middle where there is not the room for that.
     const clear = Math.min(EDGE, (farRight - farLeft) / 2)
-    const aim = clamp(channel.centre(ahead) + k.hold + carve, farLeft + clear, farRight - clear)
+    // Past an island, down the middle of the water on its side of it.
+    const line = channel.island(ahead) > 0 ? (farLeft + farRight) / 2 + carve / 2 : channel.centre(ahead) + k.hold + carve
+    const aim = clamp(line, farLeft + clear, farRight - clear)
     wanted = clamp(wrap(Math.atan2(aim - k.x, LOOK_AHEAD) - k.heading) / 0.8, -MAX_TURN, MAX_TURN)
   }
 
diff --git a/src/world/lake.ts b/src/world/lake.ts
index f815f36..543b807 100644
--- a/src/world/lake.ts
+++ b/src/world/lake.ts
@@ -41,6 +41,15 @@ export interface Channel {
   slope(s: number): number
   /** From the middle to either shore, metres. */
   halfWidth(s: number): number
+  /**
+   * How far from the middle of the channel to either shore of the island standing in
+   * it, metres; 0 where there is none. An island is a long, low bank of reeds with water
+   * both sides: the channel parts round it and is one again beyond it. `halfWidth` has
+   * the island in it, so the water either side is as wide as the channel would have been.
+   */
+  island(s: number): number
+  /** Which side of that island a skater who does nothing is taken: -1 left, 1 right. */
+  islandSide(s: number): -1 | 1
   /**
    * The islets standing in the ice between `from` and `to` metres along, in the order
    * they are come to.
@@ -132,6 +141,15 @@ const ISLET_EVERY = 50
 const ISLET_RADIUS = [2.5, 7.5] as const
 const ISLET_CLEAR = 9
 const ISLET_WATER = 16
+/**
+ * Islands: there is room for one every `ISLAND_EVERY` metres, and most of those that
+ * are not near a fork have one. It is `ISLAND_LENGTH` long and, at its middle, `ISLAND_HALF` from its own
+ * middle to either shore, coming to a point at both ends. None near a fork, nor in water
+ * as narrow as a pass.
+ */
+const ISLAND_EVERY = 900
+const ISLAND_LENGTH = [180, 480] as const
+const ISLAND_HALF = [6, 16] as const
 /** The first fork, and then how soon and how late after one fork the next comes, metres. */
 const FIRST_FORK = 700
 const NEXT_FORK = [1800, 4200] as const
@@ -207,12 +225,28 @@ export function createLake(seed: number, options: LakeOptions = { impossibleIce:
     }
 
     const centre = (s: number): number => wander(s) + mix(parentShift, shift, away(s))
+    /** The island at `s`, if it is this channel's own to have one there: half its width. */
+    const ownIsland = (s: number): number => {
+      const index = Math.floor(s / ISLAND_EVERY)
+      if (hash(lane, 8000 + index) > 0.9) return 0
+      // Somewhere in its place that leaves room for the longest island before the next place.
+      const from = (index + 0.05 + 0.35 * hash(lane, 5000 + index)) * ISLAND_EVERY
+      const length = mix(ISLAND_LENGTH[0], ISLAND_LENGTH[1], hash(lane, 6000 + index))
+      if (s <= from || s >= from + length) return 0
+      if (from < born + FORK_ZONE + 200 || from + length > ends - 400) return 0
+      // Not in a pass, nor anywhere the lake is on its way into or out of one.
+      for (let part = 0; part <= 8; part++) if (ownWidth(from + (length * part) / 8) < 15) return 0
+      return mix(ISLAND_HALF[0], ISLAND_HALF[1], hash(lane, 7000 + index)) * Math.sin((Math.PI * (s - from)) / length)
+    }
+    const island = (s: number): number => (parent && s < born ? parent.island(s) : s < born + FORK_RAMP ? 0 : ownIsland(s))
     /** The islet in the `index`th place there is room for one, if there is one in it. */
     const islet = (index: number): Islet | undefined => {
       const s = (index + 0.2 + 0.6 * hash(lane, 1000 + index)) * ISLET_EVERY
       // None while the other channel of a fork is still in sight, nor coming up to one.
       if (s < born + FORK_ZONE + 100 || s > ends - 300) return undefined
       if (noise(s / 900, key + 300) < 0.25 || hash(lane, 2000 + index) > 0.7) return undefined
+      // Nor beside an island, nor just off either end of one.
+      if (ownIsland(s) > 0 || ownIsland(s - 40) > 0 || ownIsland(s + 40) > 0) return undefined
       const width = self.halfWidth(s)
       if (width < ISLET_WATER) return undefined
       const radius = mix(ISLET_RADIUS[0], ISLET_RADIUS[1], hash(lane, 3000 + index) ** 2)
@@ -225,7 +259,9 @@ export function createLake(seed: number, options: LakeOptions = { impossibleIce:
     const self: Channel = {
       centre,
       slope: (s) => centre(s + 0.5) - centre(s - 0.5),
-      halfWidth: (s) => (parent && s < born + FORK_RAMP ? mix(parent.halfWidth(s), ownWidth(s), away(s)) : ownWidth(s)),
+      halfWidth: (s) => (parent && s < born + FORK_RAMP ? mix(parent.halfWidth(s), ownWidth(s), away(s)) : ownWidth(s) + ownIsland(s)),
+      island,
+      islandSide: (s) => (parent && s < born ? parent.islandSide(s) : hash(lane, 9000 + Math.floor(s / ISLAND_EVERY)) < 0.5 ? -1 : 1),
       islets: (from, to) => {
         // Before the fork that made it, a channel is the one it forked from, islets and all.
         const found = parent && from < born ? parent.islets(from, Math.min(to, born)) : []
diff --git a/test/lake.test.ts b/test/lake.test.ts
index f99f70d..c6efa1e 100644
--- a/test/lake.test.ts
+++ b/test/lake.test.ts
@@ -211,6 +211,29 @@ describe('lake', () => {
     expect(withIslets / stretches).toBeLessThan(0.4)
   })
 
+  it('has long islands with water both sides, clear of forks and of islets', () => {
+    let islands = 0
+    let within = false
+    let widest = 0
+    follow(300_000, 5, (channel, s) => {
+      const island = channel.island(s)
+      if (island > 0 && !within) islands++
+      within = island > 0
+      if (island === 0) return
+      widest = Math.max(widest, island)
+      expect(island).toBeLessThanOrEqual(16)
+      // Water either side about as wide as the narrowest ordinary channel is to a side.
+      expect(channel.halfWidth(s) - island).toBeGreaterThanOrEqual(ORDINARY_HALF_WIDTH[0] - 2)
+      expect([-1, 1]).toContain(channel.islandSide(s))
+      if (channel.birth) expect(s).toBeGreaterThan(channel.birth.s + FORK_ZONE)
+      expect(s).toBeLessThan(channel.end.s - 300)
+      expect(channel.islets(s - 30, s + 30)).toEqual([])
+    })
+    console.info(`${islands} islands in 300 km`)
+    expect(islands).toBeGreaterThan(40)
+    expect(widest).toBeGreaterThan(12)
+  })
+
   it('gives the autopilot a line that wanders slowly from side to side', () => {
     let leftmost = 0
     let rightmost = 0
diff --git a/test/skater.test.ts b/test/skater.test.ts
index 254fb0e..5d2e22f 100644
--- a/test/skater.test.ts
+++ b/test/skater.test.ts
@@ -25,6 +25,23 @@ const out = (k: Skater): number => {
 /** How much ice there is between the skater and the nearest islet's edge, metres. */
 const clearOfIslets = (k: Skater): number =>
   Math.min(Infinity, ...k.channel.islets(k.s - 30, k.s + 30).map((i) => Math.hypot(k.x - i.x, k.s - i.s) - i.radius))
+/** How much water there is between the skater and the island in their channel, metres; all there could be where there is none. */
+const clearOfIsland = (k: Skater): number => (k.channel.island(k.s) > 0 ? Math.abs(off(k)) - k.channel.island(k.s) : Infinity)
+/** Where the skater's channel next has an island at its widest, from `from` on. */
+function nextIsland(from: number): number {
+  let channel = lake.start
+  let s = from
+  for (; ; s += 5) {
+    while (channel.end.s <= s) channel = channel.end.preferred
+    if (channel.island(s) > 5 && channel.island(s + 5) < channel.island(s)) return s
+  }
+}
+/** The channel a skater who does nothing is in at `s`. */
+function channelAtIsland(s: number): Skater['channel'] {
+  let channel = lake.start
+  while (channel.end.s <= s) channel = channel.end.preferred
+  return channel
+}
 /** Someone steering this way and that, and sometimes not at all: the same someone every time. */
 function fidget(seed: number): (t: number) => number {
   const random = mulberry32(seed)
@@ -51,6 +68,7 @@ describe('skater', () => {
     let slowest = Infinity
     let fastest = 0
     let widest = 0
+    const islands = new Set<number>()
     const start = k.s
     for (let t = 0; t < 3600; t += DT) {
       const before = k.s
@@ -60,8 +78,10 @@ describe('skater', () => {
       expect(k.under).toBe(0)
       // On the ice, always: never on the shore side of the two metres kept clear of it.
       if (alone(k)) expect(Math.abs(off(k))).toBeLessThan(k.channel.halfWidth(k.s) - 0.5)
-      // Round every islet, with room to spare.
+      // Round every islet, with room to spare, and down one side of every island.
       expect(clearOfIslets(k)).toBeGreaterThan(ISLET_MARGIN)
+      expect(clearOfIsland(k)).toBeGreaterThan(SHORE_MARGIN / 2)
+      if (k.channel.island(k.s) > 0) islands.add(Math.sign(off(k)))
       if (k.channel !== channel) {
         // Doing nothing takes the way the fork prefers.
         expect(k.channel === channel.end.preferred).toBe(true)
@@ -72,6 +92,8 @@ describe('skater', () => {
       widest = Math.max(widest, Math.abs(off(k)))
     }
     expect(forks).toBeGreaterThan(5)
+    // Some islands passed on the left and some on the right, as each prefers.
+    expect([...islands].sort()).toEqual([-1, 1])
     // It carves: well off the middle at times, where there is the room.
     expect(widest).toBeGreaterThan(4)
     // The speed breathes with the stride, around its cruising speed and never far from it.
@@ -122,6 +144,7 @@ describe('skater', () => {
           expect(Math.abs(off(k))).toBeLessThan(Math.min(MAX_REACH + SHORE_MARGIN, k.channel.halfWidth(k.s)) - 0.5)
         }
         nearest = Math.min(nearest, clearOfIslets(k))
+        expect(clearOfIsland(k)).toBeGreaterThan(0.5)
         // Never on one. (Steered straight at one they come inside its margin for a moment.)
         expect(nearest).toBeGreaterThan(0.2)
       }
@@ -185,6 +208,20 @@ describe('skater', () => {
     expect(k.channel === other).toBe(true)
   })
 
+  it('passes an island on the side it was steered towards on the way up to it', () => {
+    const middle = nextIsland(2000)
+    for (const steer of [-1, 1]) {
+      // From 150 m short of the island's near end: a touch, and then nothing.
+      let start = middle
+      while (channelAtIsland(start).island(start) > 0) start -= 5
+      const k = createSkater(lake, start - 100)
+      run(k, steer, 0.6)
+      while (k.s < middle) stepSkater(k, straight, 0, DT)
+      expect(Math.sign(off(k))).toBe(steer)
+      expect(Math.abs(off(k))).toBeGreaterThan(k.channel.island(k.s) + SHORE_MARGIN)
+    }
+  })
+
   it('keeps to where it was steered for a moment, then eases back to the middle', () => {
     const k = createSkater(lake)
     run(k, 1, 1.5)