Back to Chase'n Those Narly Waves
SOURCE / PINNED RELEASE

Made of little things.

Chase'n Those Narly Waves

Release
0c7dde9d4921…
Author-recorded commit
88e58516be17…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-836af5a08b9

Archive hash verified: e1acfdbe67c54dd4…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game.ts
// All the game rules: moving, catching waves, tricks, rocks, sharks and score.
// Nothing in here draws anything. Drawing lives in render.ts.

import type { Arrow, Input } from './input.js';
import { LONGEST_COMBO, TRICKS_LONGEST_FIRST, randomCheer, type Trick } from './tricks.js';

export type Phase = 'title' | 'playing' | 'paused' | 'over';

/** No walls! Rocks and sharks appear this far to each side of wherever you are. */
export const SPAWN_WIDTH = 9;
/** Where new things appear, far out at sea. */
export const FAR = 60;
/**
 * Catching waves is all about timing! Tap W while the wave is in the green zone
 * (this many meters away). Tap in the gold zone for a PERFECT catch.
 */
export const CATCH_ZONE = { far: 6, near: 0.3 };
export const PERFECT_ZONE = { far: 3, near: 1 };
/** The timing bar shows the wave from this far away. Tapping before the green zone is "too early". */
export const TIMING_RANGE = 15;

/** How many hearts you start with. */
export const START_HEARTS = 5;
/** How long one wave ride lasts (seconds). */
export const RIDE_TIME = 3;
const MAX_RIDE_TIME = 5;
const COMBO_WINDOW = 0.9; // seconds you have between each arrow press
/** Turn the same way longer than this (seconds) and you fall over! */
export const TURN_LIMIT = 1.5;
const FALL_TIME = 1.2;

export interface Obstacle {
  kind: 'rock' | 'shark';
  x: number;
  z: number;
  size: number;
  seed: number;
  done: boolean;
  /** Seconds into a shark's big jump out of the water (null = swimming). */
  jump: number | null;
  /** Which way the shark leaps: -1 = left, 1 = right. */
  jumpDir: number;
  /** The last wave that crashed over this rock (so it only splashes once per wave). */
  washedBy?: Wave;
}

/** How long a shark's leap out of the water lasts (seconds). */
export const SHARK_JUMP_TIME = 1.1;

export interface Popup {
  text: string;
  sub: string;
  age: number;
  life: number;
  color: string;
  slot: number;
  big: boolean;
}

export interface Splash {
  x: number;
  z: number;
  height: number;
  vx: number;
  vHeight: number;
  life: number;
}

export interface Wave {
  /** Where the wave's top is. Negative = behind you, positive = ahead of you. */
  z: number;
  /** 'coming' toward you, 'carrying' you, or 'passing' by you and away. */
  mode: 'coming' | 'carrying' | 'passing';
  /** Waves grow bigger when you catch them. */
  size: number;
  /** You get one tap per wave. */
  tried: boolean;
}

/** Where the wave's top sits while it carries you: just behind you, pushing you forward. */
export const CARRY_Z = -0.3;
/** New waves start this far behind you. */
export const WAVE_START_BEHIND = 40;
/** How fast waves roll forward, compared to you. */
const WAVE_SPEED = 10;

/** How tall a wave is (in meters) for each step of its size. Big waves, bro! */
export const WAVE_HEIGHT = 2.2;
/** How wide the wave is in front (steep side) and behind (gentle side), in meters. */
export const WAVE_FRONT_WIDTH = 3.2;
export const WAVE_BACK_WIDTH = 7;

/** How high the big wave lifts the water at a spot (matches what you see). */
export function waveWaterHeight(wave: Wave | null, z: number): number {
  if (!wave) return 0;
  const d = z - wave.z;
  const width = d > 0 ? WAVE_FRONT_WIDTH : WAVE_BACK_WIDTH; // steep in front, gentle behind
  return wave.size * WAVE_HEIGHT * Math.exp(-(d * d) / (width * width));
}

/** Is this rock hidden under a wave right now? */
function rockUnderWater(g: Game, rock: Obstacle): boolean {
  return waveWaterHeight(g.wave, rock.z) > rock.size * 0.6;
}

/** How far behind you a wave is (in meters). */
export function waveBehind(wave: Wave): number {
  return -wave.z;
}

export interface Game {
  phase: Phase;
  time: number;
  score: number;
  best: number;
  hearts: number;
  x: number;
  vx: number;
  speed: number;
  paddle: number;
  /** Seconds of wave riding left (0 = not riding). */
  riding: number;
  /** Smoothly goes 0 → 1 when a ride starts, for drawing the wave wall. */
  rideBlend: number;
  combo: number;
  trick: { trick: Trick; t: number } | null;
  arrows: { arrow: Arrow; t: number }[];
  invulnerable: number;
  /** How long you've been turning the same way (seconds). */
  turnTime: number;
  turnDir: number;
  warnedTurn: boolean;
  /** Seconds left lying in the water after falling over. */
  fallen: number;
  /** Which way you fell: -1 = left, 1 = right. */
  fallDir: number;
  shake: number;
  scroll: number;
  /** The wave: rolling in, carrying you, or rolling on past you. */
  wave: Wave | null;
  nextSwell: number;
  nextRock: number;
  nextShark: number;
  obstacles: Obstacle[];
  popups: Popup[];
  splashes: Splash[];
  lastNag: number;
}

export function createGame(best: number): Game {
  return {
    phase: 'title',
    time: 0,
    score: 0,
    best,
    hearts: START_HEARTS,
    x: 0,
    vx: 0,
    speed: 8,
    paddle: 0,
    riding: 0,
    rideBlend: 0,
    combo: 0,
    trick: null,
    arrows: [],
    invulnerable: 0,
    turnTime: 0,
    turnDir: 0,
    warnedTurn: false,
    fallen: 0,
    fallDir: 1,
    shake: 0,
    scroll: 0,
    wave: null,
    nextSwell: 2.5,
    nextRock: 1.5,
    nextShark: 8,
    obstacles: [],
    popups: [],
    splashes: [],
    lastNag: -10,
  };
}

const random = (min: number, max: number): number => min + Math.random() * (max - min);
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;

export function popup(g: Game, text: string, sub = '', color = '#ffd23f', big = true): void {
  // Use the first free row so pop-ups don't cover each other.
  const used = new Set(g.popups.map((p) => p.slot));
  let slot = 0;
  while (used.has(slot) && slot < 2) slot += 1;
  g.popups.push({ text, sub, age: 0, life: big ? 1.6 : 1.3, color, slot, big });
}

/** Is the surfer up in the air right now (in the middle of an air trick)? */
export function isAirborne(g: Game): boolean {
  if (!g.trick || !g.trick.trick.airborne) return false;
  const progress = g.trick.t / g.trick.trick.duration;
  return progress > 0.1 && progress < 0.9;
}

export function update(g: Game, input: Input, dt: number): void {
  g.time += dt;
  updateEffects(g, dt);
  if (g.phase !== 'playing') return;

  // Gets harder over the first 2 minutes.
  const difficulty = Math.min(1, g.time / 120);

  steer(g, input, dt);
  paddleAndSpeed(g, input, dt);
  updateWaves(g, dt, input.takeTap('w'));
  for (const arrow of input.takeArrows()) handleArrow(g, arrow);
  updateTrick(g, dt);
  spawnObstacles(g, dt, difficulty);
  moveObstacles(g, dt, difficulty);
  makeSpray(g);

  g.invulnerable = Math.max(0, g.invulnerable - dt);
}

function steer(g: Game, input: Input, dt: number): void {
  // Lying in the water after a fall: no steering until you're back up.
  if (g.fallen > 0) {
    g.fallen = Math.max(0, g.fallen - dt);
    g.vx *= Math.pow(0.03, dt);
    g.x += g.vx * dt;
    return;
  }

  const direction = input.steer;
  checkBalance(g, direction, dt);
  if (g.phase !== 'playing' || g.fallen > 0) return;

  const grip = g.riding > 0 ? 34 : 24;
  g.vx += direction * grip * dt;
  g.vx *= Math.pow(0.03, dt); // water slows you down
  g.vx = Math.max(-7, Math.min(7, g.vx));
  g.x += g.vx * dt; // the ocean goes on forever to the sides
}

/** Turning one way for too long makes you lose your balance. */
function checkBalance(g: Game, direction: number, dt: number): void {
  if (direction !== 0 && direction === g.turnDir) {
    g.turnTime += dt;
  } else if (direction !== 0) {
    g.turnDir = direction; // switched sides: balance starts fresh
    g.turnTime = 0;
    g.warnedTurn = false;
  } else {
    g.turnTime = Math.max(0, g.turnTime - dt * 2); // going straight helps you recover
    if (g.turnTime === 0) g.warnedTurn = false;
  }

  if (!g.warnedTurn && g.turnTime > TURN_LIMIT * 0.55) {
    g.warnedTurn = true;
    popup(g, 'WHOA! WOBBLY!', 'Straighten out or you will fall!', '#ffb347', false);
  }

  if (g.turnTime >= TURN_LIMIT) {
    g.fallen = FALL_TIME;
    g.turnTime = 0;
    g.warnedTurn = false;
    hurt(g, 'fall');
    g.fallDir = g.turnDir;
    g.vx = g.turnDir * 3; // tumble off to the side
  }
}

function paddleAndSpeed(g: Game, input: Input, dt: number): void {
  // Holding W paddles a bit faster (it doesn't catch waves; that's all timing).
  const paddling = input.paddling && g.fallen <= 0 && g.riding <= 0 ? 1 : 0;
  g.paddle += (paddling - g.paddle) * Math.min(1, dt * 3);

  // Paddling is slow and calm; riding a wave is fast!
  const target = g.fallen > 0 ? 2 : g.riding > 0 ? 22 : input.braking ? 3 : 5 + g.paddle * 2;
  g.speed += (target - g.speed) * Math.min(1, dt * 3);
  g.scroll += g.speed * dt;
  g.score += Math.max(0, g.speed) * dt * (g.riding > 0 ? 1 : 0.5);
}

function updateWaves(g: Game, dt: number, tappedW: boolean): void {
  if (g.riding > 0) {
    g.riding -= dt;
    if (g.riding <= 0) {
      if (g.trick) g.riding = 0.01; // let the trick finish first
      else endRide(g);
    }
  }

  const wave = g.wave;

  if (!wave) {
    g.nextSwell -= dt;
    if (g.nextSwell <= 0) g.wave = { z: -WAVE_START_BEHIND, mode: 'coming', size: 1, tried: false };
  } else if (wave.mode === 'coming') {
    // The wave rolls up from behind you at a steady speed, so the timing is fair.
    wave.z += WAVE_SPEED * dt;
    const behind = waveBehind(wave);

    if (tappedW && !wave.tried && g.fallen <= 0 && behind < TIMING_RANGE) {
      wave.tried = true;
      if (behind <= CATCH_ZONE.far) {
        catchWave(g, behind <= PERFECT_ZONE.far && behind >= PERFECT_ZONE.near);
        wave.mode = 'carrying';
      } else {
        popup(g, 'TOO EARLY!', 'Wait for the green zone, bro', '#ffb347', false);
      }
    }

    if (wave.mode === 'coming' && waveBehind(wave) <= CATCH_ZONE.near) {
      if (!wave.tried) popup(g, 'MISSED IT!', 'Tap W when the wave is in the green zone', '#9fe8ff', false);
      wave.mode = 'passing'; // it rolls right under you and on ahead
    }
  } else if (wave.mode === 'carrying') {
    // The wave stays under you and grows as it carries you along.
    wave.z += (CARRY_Z - wave.z) * Math.min(1, dt * 4);
    wave.size += (1.7 - wave.size) * Math.min(1, dt * 2);
    if (g.riding <= 0) wave.mode = 'passing'; // ride's over (or you wiped out)
  } else {
    // Rolling on ahead of you toward the beach, until it fades away.
    wave.z += WAVE_SPEED * dt;
    if (wave.z > 35) {
      g.wave = null;
      g.nextSwell = random(3, 5.5);
    }
  }

  const goal = g.riding > 0 ? 1 : 0;
  g.rideBlend += Math.sign(goal - g.rideBlend) * Math.min(Math.abs(goal - g.rideBlend), dt * 2);
}

function catchWave(g: Game, perfect: boolean): void {
  g.riding = RIDE_TIME;
  g.combo = 0;
  g.arrows = [];
  if (perfect) {
    g.score += 200;
    popup(g, 'PERFECT TIMING!', 'Caught the wave! +200', '#ffd23f');
  } else {
    popup(g, 'CAUGHT THE WAVE!', 'Now do tricks with the arrow keys!', '#2de1c2');
  }
}

function endRide(g: Game): void {
  g.riding = 0;
  g.trick = null;
  if (g.combo >= 3) popup(g, 'WHAT A RIDE!', `${g.combo} tricks in one wave`, '#ff7ac4');
  g.combo = 0;
}

function handleArrow(g: Game, arrow: Arrow): void {
  if (g.fallen > 0) return;
  if (g.riding <= 0) {
    if (g.time - g.lastNag > 2) {
      popup(g, 'CATCH A WAVE FIRST!', 'Tricks only work while riding', '#9fe8ff', false);
      g.lastNag = g.time;
    }
    return;
  }
  if (g.trick) return;

  // Too long since your last press? Start a fresh combo.
  const lastPress = g.arrows[g.arrows.length - 1];
  if (lastPress && g.time - lastPress.t > COMBO_WINDOW) g.arrows = [];
  g.arrows.push({ arrow, t: g.time });
  g.arrows = g.arrows.slice(-LONGEST_COMBO);
  const pressed = g.arrows.map((entry) => entry.arrow);

  for (const trick of TRICKS_LONGEST_FIRST) {
    const tail = pressed.slice(-trick.combo.length);
    if (tail.length === trick.combo.length && tail.every((a, i) => a === trick.combo[i])) {
      g.trick = { trick, t: 0 };
      g.arrows = [];
      return;
    }
  }
}

function updateTrick(g: Game, dt: number): void {
  if (!g.trick) return;
  g.trick.t += dt;
  if (g.trick.t < g.trick.trick.duration) return;

  // Landed it!
  const { trick } = g.trick;
  g.trick = null;
  g.combo += 1;
  const points = Math.round(trick.points * (1 + (g.combo - 1) * 0.5));
  g.score += points;
  g.riding = Math.min(MAX_RIDE_TIME, g.riding + 0.5); // tricks keep the wave going a little
  const comboNote = g.combo > 1 ? `  x${g.combo} COMBO` : '';
  popup(g, randomCheer(), `${trick.name} +${points}${comboNote}`);
}

function spawnObstacles(g: Game, dt: number, difficulty: number): void {
  g.nextRock -= dt;
  if (g.nextRock <= 0) {
    g.obstacles.push({
      kind: 'rock',
      x: g.x + random(-SPAWN_WIDTH, SPAWN_WIDTH),
      z: FAR,
      size: random(0.6, 1.2),
      seed: Math.random() * 1000,
      done: false,
      jump: null,
      jumpDir: 1,
    });
    g.nextRock = lerp(2.2, 0.9, difficulty) * random(0.6, 1.4);
  }

  g.nextShark -= dt;
  if (g.nextShark <= 0) {
    g.obstacles.push({
      kind: 'shark',
      x: g.x + random(-SPAWN_WIDTH, SPAWN_WIDTH),
      z: FAR * 0.85,
      size: 1,
      seed: Math.random() * 1000,
      done: false,
      jump: null,
      jumpDir: 1,
    });
    g.nextShark = lerp(9, 4, difficulty) * random(0.7, 1.3);
  }
}

function moveObstacles(g: Game, dt: number, difficulty: number): void {
  for (const obstacle of g.obstacles) {
    if (obstacle.kind === 'rock') washOverRock(g, obstacle);
    if (obstacle.jump !== null) {
      sharkLeap(g, obstacle, dt);
      continue;
    }
    obstacle.z -= g.speed * dt;

    if (obstacle.kind === 'shark') {
      // Sharks swim toward you, and turn to follow you once they're close.
      // They can't keep up as well when you're riding a fast wave.
      obstacle.z -= 2 * dt;
      if (obstacle.z < 35 && obstacle.z > 0) {
        const chase = g.riding > 0 ? 0.7 : 1.5 + difficulty;
        const dx = g.x - obstacle.x;
        obstacle.x += Math.sign(dx) * Math.min(Math.abs(dx), chase * dt);
      }
    }

    if (!obstacle.done && Math.abs(obstacle.z) < 0.7) {
      const reach = obstacle.kind === 'rock' ? 0.45 + obstacle.size * 0.4 : 0.7;
      if (Math.abs(obstacle.x - g.x) < reach) {
        obstacle.done = true;
        if (obstacle.kind === 'rock') {
          if (isAirborne(g)) {
            g.score += 150;
            popup(g, 'OVER THE ROCK!', '+150', '#2de1c2', false);
          } else if (rockUnderWater(g, obstacle)) {
            g.score += 100;
            popup(g, 'SURFED OVER THE ROCK!', 'The wave covered it! +100', '#2de1c2', false);
          } else {
            crashIntoRock(g, obstacle);
          }
        } else if (g.riding > 0) {
          // Riding a wave is too fast for sharks!
          g.score += 100;
          popup(g, 'TOO FAST FOR THE SHARK!', '+100', '#2de1c2', false);
        } else {
          // The shark bursts out of the water and leaps right over you!
          obstacle.jump = 0;
          obstacle.jumpDir = obstacle.x < g.x ? 1 : -1;
          obstacle.x = g.x - obstacle.jumpDir * 1.3; // start on one side, land on the other
          obstacle.z = -0.2; // just in front of you, so it flies over your head
          if (g.invulnerable <= 0) hurt(g, 'shark');
        }
      }
    }
  }
  g.obstacles = g.obstacles.filter((obstacle) => obstacle.z > -3);
}

/** When a wave's crest rolls over a rock, it crashes into it with a big white splash. */
function washOverRock(g: Game, rock: Obstacle): void {
  const wave = g.wave;
  if (!wave || rock.washedBy === wave || rock.z > 30 || rock.z < -6) return;
  if (Math.abs(wave.z - rock.z) > 0.8) return;
  rock.washedBy = wave;
  for (let i = 0; i < 45; i++) {
    g.splashes.push({
      x: rock.x + random(-0.8, 0.8) * rock.size,
      z: rock.z + random(-0.3, 0.6),
      height: rock.size * 0.5,
      vx: random(-2.5, 2.5),
      vHeight: random(3, 6.5),
      life: random(0.5, 1.1),
    });
  }
}

/**
 * Rocks are solid! You smack into it, bounce back and to the side, and fall in.
 * On a wave you keep your hearts (but the ride is over). Off a wave it costs a heart.
 */
function crashIntoRock(g: Game, rock: Obstacle): void {
  const onWave = g.riding > 0;
  if (!onWave && g.invulnerable <= 0) hurt(g, 'rock');
  if (g.phase !== 'playing') return;

  if (onWave) {
    g.riding = 0;
    g.combo = 0;
    g.invulnerable = 1.0;
    popup(g, 'BONK!', 'You bounced off a rock, bro!', '#ffb347');
  }

  // The rock stays in front of you (you can't go through it)...
  rock.z = Math.max(rock.z, 0.8);
  // ...and you bounce away from it.
  const away = g.x >= rock.x ? 1 : -1;
  g.trick = null;
  g.paddle = 0;
  g.speed = -5; // knocked backward
  g.vx = away * 7;
  g.fallen = FALL_TIME;
  g.fallDir = away;
  g.shake = 0.45;

  // Big splash where you hit.
  for (let i = 0; i < 18; i++) {
    g.splashes.push({
      x: (g.x + rock.x) / 2 + random(-0.4, 0.4),
      z: random(0, 0.6),
      height: 0.2,
      vx: random(-3, 3),
      vHeight: random(2, 5),
      life: random(0.4, 0.9),
    });
  }
}

/** A shark mid-leap moves along with you, then dives back under and swims off. */
function sharkLeap(g: Game, shark: Obstacle, dt: number): void {
  shark.jump = (shark.jump ?? 0) + dt;
  shark.x += shark.jumpDir * (2.6 / SHARK_JUMP_TIME) * dt;
  if (shark.jump >= SHARK_JUMP_TIME) {
    // Big splash where it dives back in.
    for (let i = 0; i < 14; i++) {
      g.splashes.push({
        x: shark.x + random(-0.4, 0.4),
        z: shark.z,
        height: 0.1,
        vx: random(-3, 3),
        vHeight: random(2, 4.5),
        life: random(0.5, 0.9),
      });
    }
    shark.z = -10; // gone, back under the water
  }
}

function hurt(g: Game, kind: 'rock' | 'shark' | 'fall'): void {
  g.hearts -= 1;
  g.invulnerable = 1.8;
  g.shake = 0.45;
  g.trick = null;
  g.riding = 0;
  g.combo = 0;
  g.paddle = 0;
  g.vx = 0;
  if (kind === 'shark') popup(g, 'CHOMP!', 'A shark got you!', '#ff4f5e');
  else if (kind === 'fall') popup(g, 'SPLASH! YOU FELL!', 'You turned too long, dude!', '#ff4f5e');
  else popup(g, 'WIPEOUT!', 'You hit a rock!', '#ff4f5e');

  if (g.hearts <= 0) {
    g.phase = 'over';
    g.score = Math.floor(g.score);
    g.best = Math.max(g.best, g.score);
  }
}

function makeSpray(g: Game): void {
  const chance = g.riding > 0 ? 0.9 : g.speed > 11 ? 0.35 : 0.1;
  if (Math.random() > chance) return;
  g.splashes.push({
    x: g.x + random(-0.3, 0.3),
    z: random(-0.4, 0.2),
    height: 0.1,
    vx: random(-2.5, 2.5) - g.vx * 0.3,
    vHeight: random(1.5, g.riding > 0 ? 4 : 2.5),
    life: random(0.4, 0.8),
  });
}

function updateEffects(g: Game, dt: number): void {
  for (const p of g.popups) p.age += dt;
  g.popups = g.popups.filter((p) => p.age < p.life);

  for (const s of g.splashes) {
    s.x += s.vx * dt;
    s.height += s.vHeight * dt;
    s.vHeight -= 9 * dt;
    s.z -= g.speed * dt * 0.3;
    s.life -= dt;
  }
  g.splashes = g.splashes.filter((s) => s.life > 0 && s.height > 0);

  g.shake = Math.max(0, g.shake - dt);
}