feat(tui): make the ocean read as animals, not a mechanism

Founder: "the ocean needs to look more alive in general … screw the cap lol
make it less deterministic more alive more ocean."

Two things made the water read as decoration rather than life, and neither
was the species list (fish, jellyfish, bubbles and the whale cameo stay
exactly as the 2026-07-23 decision left them).

**The school moved as one rigid body.** Bob phase and tail pose were already
staggered per fish, but horizontal position was locked to an exact wedge
offset, so seven animals translated across the field as a single object.
Each fish now eases one dot fore and aft of its slot on its own slow period,
so the formation breathes while it travels. Amplitude is an order of
magnitude under the crossing speed, so no fish ever travels against the
school and `facing == velocity` still holds by construction. Costs no marks.

**Every period was an arithmetic series.** Bubble phases at `b * 1_900`,
fish bobs at `3_400 + m * 640` — evenly spaced constants, which is precisely
what makes a field read as a mechanism keeping time. `entity_jitter` now
derives each entity's phase and period from a hash of its index. It stays a
pure function: no RNG state, no clock, no simulation, and the same `t` paints
the same frame, which the delta/interpolation path requires. Irregular, not
random.

Bubble streams go 1/2/2 → 2/4/6 and the mark budget 13 → 17 to match.
Bubbles were pinned to two lanes at `width/8` and `7*width/8`, so extra
streams would have stacked into the same two columns; they now spread over
the width, and `water()` still refuses any column the composition owns, so a
denser field thins itself as a transcript fills.

The budget lived in two places — `MAX_FRAME_MARKS` and a literal `13` in the
sweep test — and only one of them moved. The test now reads the constant.

Also swaps the site's terminal screenshot to the 0.10.0 build, with its real
dimensions, source commit and hash.

Evidence:
  cargo fmt --all -- --check                      -> clean
  cargo test -p codewhale-tui --lib ambient       -> 85 passed; 0 failed
  cargo test -p codewhale-tui --lib underwater    -> 45 passed; 0 failed; 1 ignored
  npx vitest run lib/media-manifest.test.ts       -> 11 passed

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5 committed 2026-09-21 20:48:58 -07:00
1 parent 8565bc94e7
commit b16cb66844
4 files changed
+71 -22

No files matched your search

+57 -16
View File
@@ -141,10 +141,15 @@ impl LifeDensity {
#[must_use]
fn bubble_streams(self) -> usize {
// Raised from 1/2/2 (founder, "screw the cap … more alive more
// ocean"). Bubbles are the cheapest life in the field: one mark
// each, no silhouette to degrade, and `water()` already refuses any
// column the composition has claimed, so a denser field thins itself
// automatically as a transcript fills.
match self {
Self::Sparse => 1,
Self::Normal => 2,
Self::Rich => 2,
Self::Sparse => 2,
Self::Normal => 4,
Self::Rich => 6,
}
}
}
@@ -189,11 +194,11 @@ pub struct AmbientFrameStats {
pub cells_written: u32,
}
/// Bounded school (7), jellyfish (4), bubbles (2), plus at most three
/// Bounded school (7), jellyfish (4), bubbles (6), plus at most three
/// 18-by-6 dot-whale widgets including their labels. No particle allocations
/// or simulation steps occur per paint after the fixed cameo tapes are cached.
#[cfg(test)]
pub const MAX_FRAME_MARKS: u32 = 13 + pet_cameo::MAX_MARKS;
pub const MAX_FRAME_MARKS: u32 = 17 + pet_cameo::MAX_MARKS;
/// Optional pointer reaction for fish dart / bubble rise.
#[derive(Debug, Clone, Copy, Default)]
@@ -354,10 +359,28 @@ fn build_frame_marks(
// lead relative to travel, so the wedge follows instead of leading.
// Right-swimmers enter from the left edge, left-swimmers from the
// right edge — both facing exactly the way they move.
// Formation drift. Every fish used to sit at an exact offset in the
// wedge, so seven animals crossed the field as one rigid object —
// the single biggest reason the water read as decoration rather than
// life. Each fish now eases one dot fore and aft of its slot on its
// own slow period, so the wedge breathes while it travels.
//
// The period is deliberately off both the bob (`3_400 + m * 640`)
// and the tail cycle (300 ms), per this module's rule that entity
// periods never match so nothing strobes in step. One dot of
// amplitude over ~6 s is far slower than the crossing speed, so a
// fish never travels against the school and `facing == velocity`
// still holds by construction. Costs no marks: the school's
// population, band and budget are unchanged.
let drift = i32::from(sine_bob(
t,
5_200 + entity_jitter(m as u128 + 617) % 3_400,
2,
)) - 1;
let x_dots = if swims_right {
cycle_dot_step - i32::from(*dx) * 2 - i32::from(body_w) * 2
cycle_dot_step - i32::from(*dx) * 2 - i32::from(body_w) * 2 + drift
} else {
i32::from(area.width) * 2 - cycle_dot_step + i32::from(*dx) * 2
i32::from(area.width) * 2 - cycle_dot_step + i32::from(*dx) * 2 - drift
};
let mut x_i32 = if ascii_safe {
if swims_right {
@@ -369,7 +392,7 @@ fn build_frame_marks(
x_dots.div_euclid(2)
};
// Native fish bob by one dot inside a cell, never by a whole text row.
let bob = sine_bob(t, 3_400 + (m as u128) * 640, 1);
let bob = sine_bob(t, 3_000 + entity_jitter(m as u128 + 41) % 2_600, 1);
let y_i32 =
i32::from(anchor_y) + i32::from(*dy) + if ascii_safe { i32::from(bob) } else { 0 };
let body = if ascii_safe {
@@ -587,14 +610,14 @@ fn build_frame_marks(
// Floating particles rise smoothly through the water column, dissolving
// gently with continuous time-based floating physics.
for b in 0..density.bubble_streams() {
let phase = (b as u128).saturating_mul(1_900);
// Edge columns — avoid center brand.
let column = if b % 2 == 0 {
area.width / 8
} else {
area.width.saturating_mul(7) / 8
};
let rise_period = BUBBLE_RISE_MS.saturating_add(phase % 900);
// Irregular phase, period and lane. Two fixed lanes at `width/8`
// and `7*width/8` meant extra streams stacked into the same two
// columns and rose on an arithmetic beat; spread them over the whole
// width and let `water()` below reject any column the composition
// owns, which is the one placement rule this module has.
let phase = entity_jitter(b as u128) % 9_000;
let column = (entity_jitter(b as u128 + 977) % u128::from(area.width.max(1))) as u16;
let rise_period = BUBBLE_RISE_MS.saturating_add(entity_jitter(b as u128 + 313) % 2_600);
let cycle = (t.saturating_add(phase) % rise_period) as f64 / rise_period as f64;
let boost = if cursor.flee_elapsed_ms.is_some() && column.abs_diff(ptr) < 10 {
2
@@ -1101,7 +1124,25 @@ pub fn occupied_text_bounds(line: &Line<'_>) -> Option<(usize, usize)> {
Some((leading, total.saturating_sub(trailing_run)))
}
/// Deterministic per-entity jitter.
///
/// Every period in this module used to be an arithmetic series — bubble
/// phases at `b * 1_900`, fish bobs at `3_400 + m * 640` — so the field read
/// as a mechanism keeping time rather than as animals. This spreads entity
/// constants irregularly while staying a pure function of the index, which
/// the delta/interpolation path requires: the module still owns no clock, no
/// simulation and no RNG state, and two runs at the same `t` paint the same
/// frame.
#[must_use]
fn entity_jitter(seed: u128) -> u128 {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for byte in seed.to_le_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
u128::from(hash)
}
fn sine_bob(elapsed_ms: u128, period_ms: u128, amplitude: u16) -> u16 {
if period_ms == 0 || amplitude == 0 {
return 0;
+9 -1
View File
@@ -1186,7 +1186,15 @@ fn native_temporal_sweep_retains_population_and_paint_bounds() {
);
saw_jelly |= count == 3;
saw_absence |= count == 0;
assert!(frame.marks.len() <= 13);
// Bound from the one constant, not a second copy of the
// number: a hardcoded 13 here outlived the budget it was meant
// to mirror when the bubble streams grew.
assert!(
frame.marks.len() as u32 <= super::MAX_FRAME_MARKS,
"frame built {} marks, budget is {}",
frame.marks.len(),
super::MAX_FRAME_MARKS
);
let mut buf = Buffer::empty(area);
let mut stats = AmbientFrameStats {
marks_built: frame.marks.len() as u32,
+5 -5
View File
@@ -30,12 +30,12 @@ import type { LocalizedText } from "./content/vocabulary";
/** Captured build identity is independent of the current source/release. */
export const TERMINAL_SCREENSHOT = {
src: "/codewhale-tui-d7a9a1c.png",
width: 1132,
height: 632,
src: "/codewhale-tui-8565bc9.png",
width: 1162,
height: 774,
version: "0.10.0",
sourceCommit: "d7a9a1c8c15635a84099d5fc2ee965146cf197c7",
sha256: "73e56158d6ec6c04933ce787c23c07322ce4d509b54050b54b12d181b09314b8",
sourceCommit: "8565bc94e79a1cb8a787661b8c9b6288d33d4edb",
sha256: "c6a90e76bf840e0829da5ac845354ad89ca4cf4b29760651816e2a799534b470",
} as const;
/** Published-asset budgets; see the module contract for what tests inspect. */
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB