#if 0
# SOUPLIKE launcher. Run the game with:   sh souplike.c
# The shell runs these lines. The C compiler skips them.
[ -n "$ZSH_VERSION" ] && emulate sh
src="$0"
dir="${XDG_CACHE_HOME:-$HOME/.cache}/souplike"
key=$( { cat "$src"; echo "$CFLAGS $RAYLIB"; } | cksum | cut -d " " -f 1 )
bin="$dir/souplike-$key"
if [ ! -x "$bin" ]; then
  command -v cc >/dev/null 2>&1 || { echo "No C compiler found. On macOS, run: xcode-select --install"; exit 1; }
  R="${RAYLIB:-/nonexistent}"
  B="$(brew --prefix 2>/dev/null)"
  KI="$(pkg-config --variable=includedir raylib 2>/dev/null)"
  KL="$(pkg-config --variable=libdir raylib 2>/dev/null)"
  inc=""
  lib=""
  for d in "$R" "$R/src" "$R/include" "$KI" "$B/include" /opt/homebrew/include /usr/local/include /usr/include; do
    [ -f "$d/raylib.h" ] && { inc="$d"; break; }
  done
  for d in "$R" "$R/src" "$R/lib" "$KL" "$B/lib" /opt/homebrew/lib /usr/local/lib /usr/lib64 /usr/lib "/usr/lib/$(uname -m)-linux-gnu"; do
    for f in "$d/libraylib.dylib" "$d/libraylib.so" "$d/libraylib.a"; do [ -e "$f" ] && lib="$d"; done
    [ -n "$lib" ] && break
  done
  if [ -z "$inc" ]; then
    echo "SOUPLIKE needs raylib, and raylib is not installed."
    if command -v brew >/dev/null 2>&1; then
      printf "Install raylib now with Homebrew? [y/N] "
      read ans
      case "$ans" in [yY]*) brew install raylib && exec sh "$src" "$@" ;; esac
    fi
    echo "macOS:  brew install raylib      (no Homebrew? see brew.sh)"
    echo "Linux:  install raylib 5 from your package manager, or build it from github.com/raysan5/raylib"
    echo "Have your own copy?  RAYLIB=/path/to/raylib sh souplike.c"
    exit 1
  fi
  case "$(uname -s)" in
    Darwin) sys="-framework OpenGL -framework Cocoa -framework IOKit -framework CoreAudio -framework CoreVideo" ;;
    *) sys="-lGL -lm -lpthread -ldl -lrt -lX11" ;;
  esac
  mkdir -p "$dir" || exit 1
  rm -f "$dir"/souplike-*
  echo "Cooking the soup. The first run builds the game. This takes a few seconds..."
  cc $CFLAGS -O2 -x c -c "$src" -o "$bin.o" -I"$inc" || exit 1
  cc "$bin.o" -o "$bin.tmp" ${lib:+-L"$lib"} ${lib:+-Wl,-rpath,"$lib"} -lraylib -lm 2>/dev/null ||
    cc "$bin.o" -o "$bin.tmp" ${lib:+-L"$lib"} ${lib:+-Wl,-rpath,"$lib"} -lraylib $sys || { rm -f "$bin.o"; exit 1; }
  rm -f "$bin.o"
  mv "$bin.tmp" "$bin" || exit 1
fi
exec "$bin" "$@"
#endif

/*
 *  SOUPLIKE  -  a roguelike about soup.  (now with graphics)
 *
 *  You made soup. It is perfect. You have no spoon.
 *  The last spoon in the world is held by a VERY LARGE DUCK at the
 *  bottom of an 8-floor dungeon. Your soup is getting cold. Go.
 *
 *  One C file. Needs raylib 5.0 or newer. All art and sound are made in code.
 *
 *  This file is also a shell script (the lines above this comment).
 *  Play:   sh souplike.c            random run
 *          sh souplike.c 4242       seeded run (same seed = same dungeon)
 *  The first run finds raylib, builds the game into ~/.cache/souplike and starts it.
 *  Later runs start at once. The game rebuilds by itself when this file changes.
 *  Cheat:  CFLAGS=-DSTART_FLOOR=8 sh souplike.c     (straight to the duck. Coward.)
 *  Own raylib copy?  RAYLIB=/path/to/raylib sh souplike.c
 *
 *  Or build it by hand:
 *  macOS:  brew install raylib
 *          cc -O2 souplike.c -o souplike -I"$(brew --prefix)/include" -L"$(brew --prefix)/lib" -lraylib
 *  Linux:  cc -O2 souplike.c -o souplike -lraylib -lm
 */

#include <ctype.h>
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

#include "raylib.h"
#include "rlgl.h"
#include "souplike_font.h"

#if defined(__EMSCRIPTEN__)
#include <emscripten/emscripten.h>
#endif

#ifndef START_FLOOR
#define START_FLOOR 1
#endif

#define TS 16          /* tile size in pixels  */
#define MW 60          /* map width in tiles   */
#define MH 20          /* map height in tiles  */
#define FLOORS 8       /* the duck lives here  */
#define KITCHEN_FLOOR 4   /* the halfway kitchen opens on the way down from this floor */
#define MAXMON 96
#define MAXITEM 128
#define INVMAX 9
#define LOGMAX 40
#define MAXROOMS 10
#define NPOT 8
#define NSCR 8
#define FAR 9999
#define MAXPART 1600
#define MAXUPART 500
#define MAXFTXT 64
#define MAXFADE 48
#define MAXRING 8
#define PI_F 3.14159265f

#if defined(__GNUC__) || defined(__clang__)
#define PRINTF_LIKE(a, b) __attribute__((format(printf, a, b)))
#else
#define PRINTF_LIKE(a, b)
#endif

/* ================================================================ rng === */

static unsigned long long rs_game = 1, rs_gen = 1, *rsp = &rs_game;
static unsigned long g_seed;

static unsigned long long mix64(unsigned long long z) {
    z += 0x9E3779B97F4A7C15ULL;
    z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
    z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
    z ^= z >> 31;
    return z ? z : 1;
}

static unsigned rnd32(void) {
    unsigned long long x = *rsp;
    x ^= x << 13;
    x ^= x >> 7;
    x ^= x << 17;
    *rsp = x;
    return (unsigned)(x >> 32);
}

static int rnd(int n) { return n <= 0 ? 0 : (int)(rnd32() % (unsigned)n); }

/* visual-only randomness, so particles never change the dungeon */
static unsigned vrs = 0x9E3779B9u;
static float frand(void) {
    vrs ^= vrs << 13;
    vrs ^= vrs >> 17;
    vrs ^= vrs << 5;
    return (float)(vrs & 0xFFFFFF) / 16777216.0f;
}
static float frange(float a, float b) { return a + (b - a) * frand(); }
static float clampf(float v, float a, float b) { return v < a ? a : v > b ? b : v; }
static float lerpf(float a, float b, float t) { return a + (b - a) * t; }

static Color mixc(Color a, Color b, float t) {
    t = clampf(t, 0, 1);
    return (Color){(unsigned char)lerpf(a.r, b.r, t), (unsigned char)lerpf(a.g, b.g, t),
                   (unsigned char)lerpf(a.b, b.b, t), (unsigned char)lerpf(a.a, b.a, t)};
}
static Color mulc(Color a, Color b) {
    return (Color){(unsigned char)(a.r * b.r / 255), (unsigned char)(a.g * b.g / 255),
                   (unsigned char)(a.b * b.b / 255), (unsigned char)(a.a * b.a / 255)};
}
static Color alphac(Color c, float a) {
    c.a = (unsigned char)(clampf(a, 0, 1) * c.a);
    return c;
}

/* ========================================================== sprites === */
/* Each sprite is pixel art written as text. One character = one pixel.
   'q' pixels go to a separate mask layer that is tinted at draw time
   (soup that changes color, potions of many flavors). */

static Color pal(char ch) {
    switch (ch) {
    case 'k': return (Color){34, 24, 38, 255};
    case 'x': return (Color){14, 10, 18, 255};
    case 'w': return (Color){246, 240, 226, 255};
    case 'g': return (Color){178, 172, 170, 255};
    case 'G': return (Color){108, 100, 110, 255};
    case 'r': return (Color){226, 70, 64, 255};
    case 'R': return (Color){140, 36, 50, 255};
    case 'o': return (Color){240, 142, 56, 255};
    case 'y': return (Color){252, 212, 78, 255};
    case 'Y': return (Color){198, 142, 40, 255};
    case 'b': return (Color){156, 100, 62, 255};
    case 'B': return (Color){98, 60, 46, 255};
    case 'n': return (Color){112, 190, 82, 255};
    case 'N': return (Color){54, 120, 66, 255};
    case 'l': return (Color){172, 236, 98, 255};
    case 'c': return (Color){120, 216, 232, 255};
    case 'C': return (Color){52, 140, 172, 255};
    case 'u': return (Color){80, 122, 222, 255};
    case 'U': return (Color){48, 64, 140, 255};
    case 'p': return (Color){172, 92, 204, 255};
    case 'P': return (Color){98, 50, 132, 255};
    case 'm': return (Color){242, 142, 172, 255};
    case 's': return (Color){250, 198, 152, 255};
    case 'S': return (Color){212, 150, 112, 255};
    case 't': return (Color){232, 204, 148, 255};
    case 'a': return (Color){108, 90, 92, 255};
    case 'A': return (Color){78, 64, 70, 255};
    case 'z': return (Color){128, 112, 112, 255};
    case 'h': return (Color){146, 106, 88, 255};
    case 'H': return (Color){98, 70, 66, 255};
    case 'T': return (Color){182, 144, 114, 255};
    case 'v': return (Color){92, 72, 84, 255};
    case 'V': return (Color){108, 86, 98, 255};
    default:  return (Color){0, 0, 0, 0};
    }
}

static const char *S_PLAYER[] = {
    "......kkkk......",
    "....kkwwwwkk....",
    "...kwwwwwwwwk...",
    "...kwwwwwwwwk...",
    "....kwwwwwwk....",
    "....kGGGGGGk....",
    "....kssssssk....",
    "....kskssksk....",
    "....kssmmssk....",
    ".....kssssk.....",
    "....kuuuuuuk....",
    "..kskqqqqqqksk..",
    "..kkkwwwwwwkkk..",
    "....kkwwwwkk....",
    ".....kUk.kUk....",
    ".....kk..kk.....",
};
static const char *S_RAT[] = {
    "................",
    "................",
    "................",
    "................",
    "................",
    "...kkk..........",
    "..kxxxk.........",
    "..kxxxk.........",
    ".kkrrrkk........",
    "..kgggkk.kkkkk..",
    ".kgkgggkkgggggk.",
    "kmgggggggggggggk",
    ".kkgggggggggggk.",
    "...kgggggggggk.k",
    "...kkGkkkkGkkkk.",
    "....kk....kk....",
};
static const char *S_SLIME[] = {
    "................",
    "................",
    "................",
    "................",
    "................",
    "................",
    "................",
    "......kkkk......",
    "....kkllllkk....",
    "...klwllllllk...",
    "..klwllllllllk..",
    "..kllkllllkllk..",
    ".klllllNNlllllk.",
    ".kNllllllllllNk.",
    "kNNNNNNNNNNNNNNk",
    ".kkkkkkkkkkkkkk.",
};
static const char *S_BAT[] = {
    "................",
    "................",
    "................",
    "................",
    ".k............k.",
    "kpk...k..k...kpk",
    "kppk..kkkk..kppk",
    "kpppkkppppkkpppk",
    "kPpppkprrpkpppPk",
    ".kPppkppppkppPk.",
    "..kPkkppppkkPk..",
    "...k..kppk..k...",
    ".......kk.......",
    "................",
    "................",
    "................",
};
static const char *S_GOBLIN[] = {
    "................",
    "................",
    "....kkkkkkkk....",
    "...knnnnnnnnk...",
    "kk.knnnnnnnnk.kk",
    "knkknnnnnnnnkknk",
    ".knkkwwkkwwkknk.",
    "..kknnnnnnnnkk..",
    "...knnNNNNnnk...",
    "....kkkkkkkk....",
    "...kwwwrrwwwk...",
    "..kwwwwrrwwwwk..",
    "..knkwwrrwwknk..",
    "...kkwwwwwwkk...",
    "....kNk..kNk....",
    "....kk....kk....",
};
static const char *S_DRAFT[] = {
    "................",
    "................",
    "......CCCC......",
    "....CCccccCC....",
    "...CcwwcccccC...",
    "..CcwccccccccC..",
    "..CckkcccckkcC..",
    "..CckkcccckkcC..",
    "..CccccccccccC..",
    "..CcccckkccccC..",
    "..CccccccccccC..",
    "...CccccccccC...",
    "....CcccCccC....",
    ".....CcC..CcC...",
    "......C....C....",
    "................",
};
static const char *S_CHAIR[] = {
    "................",
    "....kkkkkkkk....",
    "....kbbbbbbk....",
    "....kbrkkrbk....",
    "....kbbbbbbk....",
    "....kbkbbkbk....",
    "....kbkbbkbk....",
    "....kbbbbbbk....",
    "..kkkkkkkkkkkk..",
    "..kttttttttttk..",
    "..kbbbbbbbbbbk..",
    "..kkkkkkkkkkkk..",
    "..kbk......kbk..",
    "..kbk......kbk..",
    "..kBk......kBk..",
    "..kkk......kkk..",
};
static const char *S_SHROOM[] = {
    "................",
    "................",
    "................",
    ".....kkkkkk.....",
    "...kkppppppkk...",
    "..kppwwppppppk..",
    ".kppwwppppwwppk.",
    ".kppppppppwwppk.",
    ".kPPPPPPPPPPPPk.",
    "..kkkkkkkkkkkk..",
    "....kttttttk....",
    "....ktkttktk....",
    "....kttttttk....",
    "....kttSSttk....",
    "...kkttttttkk...",
    "...kkkkkkkkkk...",
};
static const char *S_MIMIC[] = {
    "................",
    "......kkkk......",
    "......kBBk......",
    ".....kkkkkk.....",
    "......kwgk......",
    ".....kwgggk.....",
    "....krrrrrrk....",
    "...krkkrrkkrk...",
    "...krrrrrrrrk...",
    "..kkwkwkwkwkwkk.",
    "..kxxxxxxxxxxk..",
    "..kkwkwkwkwkwkk.",
    "...krrrrrrrrk...",
    "....kRRRRRRk....",
    ".....kkkkkk.....",
    "................",
};
static const char *S_OGRE[] = {
    "..........kk....",
    "........kkwk....",
    "......kkuukk....",
    "....kkuuuuuk....",
    "...kuuuuuuuuk...",
    "..kwwwwwwwwwwk..",
    "..knnnnnnnnnnk..",
    ".knnkkknnkkknnk.",
    ".knnnnnnnnnnnnk.",
    ".knnnnwwwwnnnnk.",
    "..knnnkkkknnnk..",
    ".kkkkBBBBBBkkkk.",
    "knnkBBBBBBBBknnk",
    "knnkBBBBBBBBknnk",
    ".kkkBBBkkBBBkkk.",
    "...kkkk..kkkk...",
};
static const char *S_DUCK[] = {
    "................",
    "...kkkkk........",
    "..kyyyyyk.......",
    ".kyykkyyyk......",
    ".kywkyyyyk......",
    "kooooyyyyk......",
    "koooooyyyk..kk..",
    ".kkkkyyyyykkyyk.",
    "....kyyyyyyyyyk.",
    "...kyyyyyyyyyyyk",
    "...kyyyYYYYyyyyk",
    "...kyyyyYYYYyyyk",
    "....kyyyyyyyyyk.",
    ".....kkkkkkkkk..",
    "......kok.kok...",
    ".....kkkk.kkkk..",
};
static const char *S_GOLD[] = {
    "................",
    "................",
    "................",
    "................",
    "................",
    "................",
    "......kkkk......",
    ".....kywyyk.....",
    ".....kyyyYk.....",
    "...kkkkkkkkkk...",
    "..kywyyykywyyk..",
    "..kyyyYYkyyyYk..",
    "..kkkkkkkkkkkk..",
    "..kYYYYYYYYYYk..",
    "...kkkkkkkkkk...",
    "................",
};
static const char *S_POTION[] = {
    "................",
    "................",
    "......kkkk......",
    "......kBBk......",
    ".....kkkkkk.....",
    "......kwgk......",
    "......kwgk......",
    ".....kwgggk.....",
    "....kwqqqqqk....",
    "...kwqqqqqqqk...",
    "...kwqqqqqqqk...",
    "...kqqqqqqqqk...",
    "...kqqqqqqqqk...",
    "....kqqqqqqk....",
    ".....kkkkkk.....",
    "................",
};
static const char *S_SCROLL[] = {
    "................",
    "................",
    "...kkkkkkkkkk...",
    "..kBttttttttBk..",
    "..kkkkkkkkkkkk..",
    "...kttttttttk...",
    "...ktBBBBBttk...",
    "...kttttttttk...",
    "...ktBBBtBBtk...",
    "...kttttttttk...",
    "...ktBBBBtttk...",
    "...kttttttttk...",
    "..kkkkkkkkkkkk..",
    "..kBttttttttBk..",
    "...kkkkkkkkkk...",
    "................",
};
static const char *S_ROCK[] = {
    "................",
    "................",
    "................",
    "................",
    "................",
    "................",
    "................",
    "................",
    "......kkkk......",
    "....kkooyokk....",
    "...koyyyoooook..",
    "..kooyyoooooRk..",
    "..kRooooooooRk..",
    "...kRRooooRRk...",
    "....kkkkkkkk....",
    "................",
};
static const char *S_WEAPON[] = {
    "................",
    "................",
    "...........kkk..",
    "..........kgwk..",
    ".........kgk....",
    "........kgk.....",
    ".......kgk......",
    "......kgk.......",
    ".....kgk........",
    "..kkkgk.........",
    ".kgwggk.........",
    "kgwgggggk.......",
    "kgggggggk.......",
    ".kgggggk........",
    "..kkkkk.........",
    "................",
};
static const char *S_ARMOR[] = {
    "................",
    "................",
    "................",
    "................",
    ".....kkkkkk.....",
    "...kkggggggkk...",
    "..kgwgGgggGggk..",
    "..kggggGggggGk..",
    ".kgGgggggGgggGk.",
    ".kggggggggggggk.",
    "kkkkkkkkkkkkkkkk",
    "kGGGGGGGGGGGGGGk",
    "kkkkkkkkkkkkkkkk",
    "................",
    "................",
    "................",
};
static const char *S_FLOOR[] = {
    "aaaaaaaAaaaaaaaA",
    "azaaaaaAaaaaaaaA",
    "aaaaaaaAaaaazaaA",
    "aaaaaaaAaaaaaaaA",
    "aaazaaaAaaaaaaaA",
    "aaaaaaaAaaaaaaaA",
    "aaaaaaaAaaaaaaaA",
    "AAAAAAAAAAAAAAAA",
    "aaaAaaaaaaaAaaaa",
    "aaaAaaaazaaAaaaa",
    "aaaAaaaaaaaAaaza",
    "aazAaaaaaaaAaaaa",
    "aaaAaaaaaaaAaaaa",
    "aaaAaazaaaaAaaaa",
    "aaaAaaaaaaaAaaaa",
    "AAAAAAAAAAAAAAAA",
};
static const char *S_WALLFACE[] = {
    "TTTTTTTTTTTTTTTT",
    "hhhhhhhHhhhhhhhH",
    "hhhhhhhHhhhhhhhH",
    "hhhhhhhHhhhhhhhH",
    "HHHHHHHHHHHHHHHH",
    "hhhHhhhhhhhHhhhh",
    "hhhHhhhhhhhHhhhh",
    "hhhHhhhhhhhHhhhh",
    "HHHHHHHHHHHHHHHH",
    "hhhhhhhHhhhhhhhH",
    "hhhhhhhHhhhhhhhH",
    "hhhhhhhHhhhhhhhH",
    "HHHHHHHHHHHHHHHH",
    "hhhHhhhhhhhHhhhh",
    "hhhHhhhhhhhHhhhh",
    "kkkkkkkkkkkkkkkk",
};
static const char *S_WALLTOP[] = {
    "vvvvvvvvvvvvvvvv",
    "vVvvvvvvvvvvvvvv",
    "vvvvvvvvvVvvvvvv",
    "vvvvvvvvvvvvvvvv",
    "vvvvVvvvvvvvvvVv",
    "vvvvvvvvvvvvvvvv",
    "vvvvvvvvvvvvvvvv",
    "vvvvvvvvVvvvvvvv",
    "vvVvvvvvvvvvvvvv",
    "vvvvvvvvvvvvvvvv",
    "vvvvvvvvvvvVvvvv",
    "vvvvvvvvvvvvvvvv",
    "vvvvvVvvvvvvvvvv",
    "vvvvvvvvvvvvvVvv",
    "vvvvvvvvvvvvvvvv",
    "vvvvvvvvvvvvvvvv",
};
static const char *S_STAIRS[] = {
    "kkkkkkkkkkkkkkkk",
    "kzzzzzzzzzzzzzzk",
    "kaaaaaaaaaaaaaak",
    "kAAAAAAAAAAAAAAk",
    "kkzzzzzzzzzzzzkk",
    "kkaaaaaaaaaaaakk",
    "kkAAAAAAAAAAAAkk",
    "kkkzzzzzzzzzzkkk",
    "kkkaaaaaaaaaakkk",
    "kkkAAAAAAAAAAkkk",
    "kkkkzzzzzzzzkkkk",
    "kkkkaaaaaaaakkkk",
    "kkkkAAAAAAAAkkkk",
    "kkkkkxxxxxxkkkkk",
    "kkkkkxxxxxxkkkkk",
    "kkkkkkkkkkkkkkkk",
};
static const char *S_VEND[] = {
    ".kkkkkkkkkkkkkk.",
    ".kuuuuuuuuuuuuk.",
    ".kukkkkkkkuuuuk.",
    ".kukrrwyykuyuuk.",
    ".kukccoookuruuk.",
    ".kuknnpppkunuuk.",
    ".kukyyrrckuuuuk.",
    ".kukkkkkkkukkuk.",
    ".kuuuuuuuuuuuuk.",
    ".kuUUUUUUUUUUuk.",
    ".kuUxxxxxxxxUuk.",
    ".kuUUUUUUUUUUuk.",
    ".kuuuuuuuuuuuuk.",
    ".kkkkkkkkkkkkkk.",
    ".kGk........kGk.",
    ".kkk........kkk.",
};
static const char *S_BOWL[] = {
    "....kkkkkkkkkkkkkkkk....",
    "..kkqqqqqqqqqqqqqqqqkk..",
    ".kqqwwqqqqqqqqqqqqqqqqk.",
    "kkqqqqqqqqqqqqqqqqqqqqkk",
    "kwkkqqqqqqqqqqqqqqqqkkwk",
    "kwwwkkkkkkkkkkkkkkkkwwwk",
    ".kwwwwwwwwwwwwwwwwwwwwk.",
    ".kwrrwwrrwwrrwwrrwwrrwk.",
    "..kwwwwwwwwwwwwwwwwwwk..",
    "...kwwwwwwwwwwwwwwwwk...",
    ".....kkkkwwwwwwkkkk.....",
    ".......kkkkkkkkkk.......",
};
static const char *S_HEART[] = {
    ".kk.kk..",
    "krrkrrk.",
    "krwrrrk.",
    "krrrrrk.",
    ".krrrk..",
    "..krk...",
    "...k....",
    "........",
};

/* ingredients, in G_* order */
static const char *S_ING_RAT[] = {
    "................",
    "......kkkkk.....",
    ".....kmmmmmk....",
    "....kmmkkkmmk...",
    "....kmk...kmk...",
    "....kkk...kmk...",
    "..........kmk...",
    ".........kmmk...",
    "........kmmk....",
    ".......kmmk.....",
    "......kmmk......",
    ".....kmmSk......",
    "....kmmSk.......",
    "...kmSSk........",
    "...kSSk.........",
    "....kk..........",
};
static const char *S_ING_SLIME[] = {
    "................",
    "....kkkkkkkk....",
    "...krwrwrwrrk...",
    "...kRRRRRRRRk...",
    "..kkkkkkkkkkkk..",
    "..kwwlllllllwk..",
    "..kwllwlllllnk..",
    "..kwllwlllllnk..",
    "..kwlllllllllk..",
    "..kwlllllnlllk..",
    "..kwllllllllNk..",
    "..kwlllllllNNk..",
    "..kwNNNNNNNNNk..",
    "...kkkkkkkkkk...",
    "................",
    "................",
};
static const char *S_ING_BAT[] = {
    "................",
    "...........kk...",
    "..........kppk..",
    ".........kppPk..",
    "....kkkkkkppPk..",
    "..kkpppppppPPk..",
    ".kppPppppPpppk..",
    ".kpPpppPpPppppk.",
    ".kpPpppPppPpppk.",
    ".kpkpPppkpPppk..",
    ".kk.kpPpkkpPk...",
    "......kkk.kk....",
    "................",
    "................",
    "................",
    "................",
};
static const char *S_ING_GOBLIN[] = {
    "................",
    "..........kk....",
    ".........knnk...",
    "........knlk....",
    ".......kkkkk....",
    "......krrrrrk...",
    "......krwrrrk...",
    ".....krwrrrRk...",
    ".....krrrrrRk...",
    ".....krrrrRk....",
    "....krrrrRRk....",
    "....krrrRRk.....",
    "...krrrRRk......",
    "...krrRRk.......",
    "....kRRk........",
    ".....kk.........",
};
static const char *S_ING_DRAFT[] = {
    "................",
    ".......kk.......",
    "......kccck.....",
    ".....kcwcck.....",
    ".....kcwccck....",
    "....kcwcccCk..w.",
    "....kcwccccCk...",
    "...kcwcccccCCk..",
    "...kccccccCCCk..",
    "...kcccccCCCCk..",
    "....kcccCCCCk...",
    "..w.kcccCCCk....",
    ".....kcCCCk.....",
    "......kCCk......",
    ".......kk.......",
    "................",
};
static const char *S_ING_CHAIR[] = {
    "................",
    "..............kk",
    ".............kTk",
    "............kTbk",
    "...........kTbbk",
    "..........kTbbk.",
    ".........kTbBbk.",
    "........kbbbBk..",
    ".......kTbbBBk..",
    "......kbbbBBk...",
    ".....kTbbBBk....",
    "....kbbBBBk.....",
    "...kTbbBBk......",
    "..kbbBBBk.......",
    "..kkbBBk........",
    "....kkk.........",
};
static const char *S_ING_SHROOM[] = {
    "................",
    "...y.....y......",
    "......y....y....",
    "......kkkk......",
    "....kkppppkk....",
    "...kppwwppppk...",
    "..kppwwpppwwpk..",
    "..kppppppppwpk..",
    "..kPPPPPPPPPPk..",
    "...kkkkkkkkkk...",
    ".....kttttk.....",
    ".....ktkttk.....",
    ".....kttSSk.....",
    "....kkkkkkkk....",
    "................",
    "................",
};
static const char *S_ING_MIMIC[] = {
    "................",
    "...kkkkkkkkk....",
    "..krrRRRRRRrk...",
    "..krrrrrrrrrk...",
    "...kwwwwwwwk....",
    "...kwwwgwwwk....",
    "...kwgwwwwwk....",
    "....kwwwwwk.....",
    "....kwwwgwk.....",
    ".....kwwwk......",
    ".....kwwgk......",
    "......kwk.......",
    "......kgk.......",
    ".......k........",
    "................",
    "................",
};
static const char *S_ING_OGRE[] = {
    "................",
    "................",
    "....kkkkkkkk....",
    "..kknnnnnnnnkk..",
    ".knnnkkkkkkknnk.",
    ".knnkyyyyyyyknk.",
    ".knnkywyyyyYknk.",
    ".knnkyyyyyYYknk.",
    ".knNkYYYYYYYknk.",
    ".knnNkkkkkkkNnk.",
    ".kNnnnnnnnnnnNk.",
    "..kNNnnnnnnNNk..",
    "...kkNNNNNNkk...",
    "....kkkkkkkk....",
    "................",
    "................",
};
static const char *S_DUCKLING[] = {
    "................",
    "................",
    "................",
    "......k.kk......",
    ".....kykyyk.....",
    "....kyyyyyyk....",
    "...kyyykyyyyk...",
    "..koookwyyyyyk..",
    "..kooooyyyyyyk..",
    "...kkyyyyyyyYk..",
    "...kyyyyyyyYYYk.",
    "...kyyyyyyyYYYk.",
    "...kyyyyyyyyyYk.",
    "....kkyyyyyYkk..",
    ".....kokkkkok...",
    ".....kkk..kkk...",
};

enum {
    SP_PLAYER, SP_RAT, SP_SLIME, SP_BAT, SP_GOBLIN, SP_DRAFT, SP_CHAIR, SP_SHROOM, SP_MIMIC, SP_OGRE, SP_DUCK,
    SP_GOLD, SP_POTION, SP_SCROLL, SP_ROCK, SP_WEAPON, SP_ARMOR,
    SP_FLOOR, SP_WALLFACE, SP_WALLTOP, SP_STAIRS, SP_VEND, SP_BOWL, SP_HEART,
    SP_ING, SP_ING_LAST = SP_ING + 8, SP_DUCKLING, SP_N
};

typedef struct { const char **rows; int w, h; } SprSrc;
#define SRC(a, w) { a, w, (int)(sizeof a / sizeof a[0]) }
static const SprSrc SPR_SRC[SP_N] = {
    SRC(S_PLAYER, 16), SRC(S_RAT, 16), SRC(S_SLIME, 16), SRC(S_BAT, 16), SRC(S_GOBLIN, 16), SRC(S_DRAFT, 16),
    SRC(S_CHAIR, 16), SRC(S_SHROOM, 16), SRC(S_MIMIC, 16), SRC(S_OGRE, 16), SRC(S_DUCK, 16),
    SRC(S_GOLD, 16), SRC(S_POTION, 16), SRC(S_SCROLL, 16), SRC(S_ROCK, 16), SRC(S_WEAPON, 16), SRC(S_ARMOR, 16),
    SRC(S_FLOOR, 16), SRC(S_WALLFACE, 16), SRC(S_WALLTOP, 16), SRC(S_STAIRS, 16), SRC(S_VEND, 16),
    SRC(S_BOWL, 24), SRC(S_HEART, 8),
    SRC(S_ING_RAT, 16), SRC(S_ING_SLIME, 16), SRC(S_ING_BAT, 16), SRC(S_ING_GOBLIN, 16), SRC(S_ING_DRAFT, 16),
    SRC(S_ING_CHAIR, 16), SRC(S_ING_SHROOM, 16), SRC(S_ING_MIMIC, 16), SRC(S_ING_OGRE, 16), SRC(S_DUCKLING, 16),
};

typedef struct { Texture2D tex, white, mask; int w, h, has_mask; } Sprite;
static Sprite spr[SP_N];
static Texture2D glow_tex;

static void build_sprites(void) {
    int i, x, y;
    for (i = 0; i < SP_N; i++) {
        const SprSrc *s = &SPR_SRC[i];
        Image img = GenImageColor(s->w, s->h, BLANK);
        Image wht = GenImageColor(s->w, s->h, BLANK);
        Image msk = GenImageColor(s->w, s->h, BLANK);
        spr[i].w = s->w;
        spr[i].h = s->h;
        spr[i].has_mask = 0;
        for (y = 0; y < s->h; y++) {
            int len = (int)strlen(s->rows[y]);
            for (x = 0; x < s->w && x < len; x++) {
                char ch = s->rows[y][x];
                Color c;
                if (ch == 'q') {
                    ImageDrawPixel(&msk, x, y, WHITE);
                    ImageDrawPixel(&wht, x, y, WHITE);
                    spr[i].has_mask = 1;
                    continue;
                }
                c = pal(ch);
                if (!c.a) continue;
                ImageDrawPixel(&img, x, y, c);
                ImageDrawPixel(&wht, x, y, WHITE);
            }
        }
        spr[i].tex = LoadTextureFromImage(img);
        spr[i].white = LoadTextureFromImage(wht);
        spr[i].mask = LoadTextureFromImage(msk);
        SetTextureFilter(spr[i].tex, TEXTURE_FILTER_POINT);
        SetTextureFilter(spr[i].white, TEXTURE_FILTER_POINT);
        SetTextureFilter(spr[i].mask, TEXTURE_FILTER_POINT);
        UnloadImage(img);
        UnloadImage(wht);
        UnloadImage(msk);
    }
    {
        Image g = GenImageGradientRadial(64, 64, 0.0f, WHITE, BLANK);
        glow_tex = LoadTextureFromImage(g);
        SetTextureFilter(glow_tex, TEXTURE_FILTER_BILINEAR);
        UnloadImage(g);
    }
}

/* ======================================================== game data === */

typedef struct {
    int spr;
    Color col;
    const char *art, *name, *verb, *death, *blurb;
    int hp, atk, def, xp, minf, weight;
} MonType;

enum { M_RAT, M_SLIME, M_BAT, M_GOBLIN, M_DRAFT, M_CHAIR, M_SHROOM, M_MIMIC, M_OGRE, M_DUCK, M_DUCKLING, M_N };

static const MonType MT[M_N] = {
    {SP_RAT, {180, 172, 176, 255}, "a", "rat in a tiny hat", "nibbles", "The rat dies. Its tiny hat lives on.",
     "Wears the hat for confidence. Cannot resist a puddle.", 4, 2, 0, 2, 1, 10},
    {SP_SLIME, {160, 236, 96, 255}, "a", "slime", "squelches", "The slime goes splat.",
     "Drinks soup and grows. Bonks split it. Rude.", 6, 2, 0, 3, 1, 8},
    {SP_BAT, {176, 96, 210, 255}, "a", "bat of poor decisions", "flaps at", "The bat makes its final poor decision.",
     "Flies at random. Like you. Dodges flings, skips puddles.", 5, 3, 0, 3, 2, 7},
    {SP_GOBLIN, {112, 190, 82, 255}, "a", "goblin accountant", "pokes", "The goblin accountant files its final report.",
     "Audits your gold or a pocketed ingredient, then runs.", 7, 2, 1, 5, 2, 5},
    {SP_DRAFT, {140, 224, 240, 255}, "a", "chilly draft", "blows on", "The chilly draft fades. Someone closed a window.",
     "Goes through walls. Chills soup and puddles. Fireproof.", 5, 0, 0, 5, 2, 3},
    {SP_CHAIR, {170, 116, 72, 255}, "a", "haunted chair", "kicks", "The haunted chair is now just a chair. Probably.",
     "Moves only when you look away. Cannot burn or stick.", 8, 3, 1, 6, 3, 4},
    {SP_SHROOM, {204, 116, 224, 255}, "a", "sporeshroom", "puffs at", "The sporeshroom wilts dramatically.",
     "Stays put. Spores confuse you. Scalding soup cooks it.", 6, 1, 0, 4, 3, 4},
    {SP_MIMIC, {232, 74, 70, 255}, "a", "mimic", "chomps", "The mimic stops pretending.",
     "Definitely not a potion. Spill soup on it to find out.", 12, 5, 1, 9, 4, 3},
    {SP_OGRE, {112, 176, 84, 255}, "an", "ogre who is just tired", "yawns and bonks", "The ogre finally gets some rest.",
     "Hot soup makes it furious. Lukewarm soup makes it nap.", 22, 6, 2, 15, 5, 3},
    {SP_DUCK, {252, 212, 78, 255}, "the", "VERY LARGE DUCK", "bites", "",
     "Holds the spoon. Hot soup makes it angry. HONK.", 48, 5, 1, 100, 99, 0},
    /* ducklings reuse the duck sprite, small, until they get their own art */
    {SP_DUCKLING, {255, 236, 120, 255}, "a", "duckling", "nips at", "The duckling goes peep. Softly.",
     "Small, fast, and mostly teeth. Licks puddles.", 3, 2, 0, 1, 99, 0},
};

typedef struct {
    int t, x, y, hp, maxhp, atk, def, alive, awake, sleep, fear, revealed, gold, honk, look;
    int phase, tele, cdx, cdy, cstun, ccd;   /* duck only: phase, charge telegraph, direction, stun, cooldown */
    int burn, burn_dmg, stuck;               /* statuses: turns left burning (and damage per turn), turns it skips */
    int dozed, fury, lick, stole;            /* ogre: asleep from lukewarm soup; furious turns left. rat, duckling: licks left. goblin: ingredient taken (id + 1) */
    float vx, vy, flash, lunge, ldx, ldy, face, bob, zzz;
} Mon;
typedef struct { int k, sub, x, y, amt, alive; } Item;
typedef struct { int k, sub; } Pocket;
typedef struct { int x, y, w, h; } Room;

enum { I_GOLD, I_POTION, I_SCROLL, I_ROCK, I_WEAPON, I_ARMOR, I_INGREDIENT };
static const int ITEM_SPR[] = {SP_GOLD, SP_POTION, SP_SCROLL, SP_ROCK, SP_WEAPON, SP_ARMOR, SP_POTION};

static const char *POT_EFF[NPOT] = {
    "healing", "strength", "hot sauce", "confusion", "teleport", "clarity", "regret", "toughness"};
static const char *SCR_EFF[NSCR] = {
    "fireball", "fear", "microwave", "bureaucracy", "gold rain", "swapping", "identify", "lullaby"};
static const char *POT_LOOK[] = {
    "fizzy teal", "murky brown", "glowing pink", "lukewarm gray", "chunky orange",
    "sparkly black", "sus green", "bubbly purple", "feet-scented", "oddly warm"};
static const Color LOOK_COL[] = {
    {60, 214, 200, 255}, {134, 92, 54, 255}, {255, 110, 204, 255}, {150, 150, 164, 255}, {244, 140, 40, 255},
    {66, 50, 92, 255}, {124, 230, 60, 255}, {170, 84, 236, 255}, {214, 204, 92, 255}, {246, 84, 52, 255}};
#define NLOOK ((int)(sizeof POT_LOOK / sizeof POT_LOOK[0]))
static const char *SYL[] = {
    "ZORB", "FLIM", "KAZ", "BLEP", "QUAX", "MOOP", "GRIB", "WOMP", "SNORF", "PLUG", "YEET", "NIB", "DORF", "SKRONK"};
#define NSYL ((int)(sizeof SYL / sizeof SYL[0]))
static const char *WEAPONS[] = {   /* ladles: each one adds to the splash */
    "bigger ladle", "slotted ladle of justice", "ladle with a grudge", "wooden spoon, but cooler",
    "whisk of menace", "frying pan (for soup, it's fine)", "soup ladle, sharpened"};
#define NWEAP ((int)(sizeof WEAPONS / sizeof WEAPONS[0]))
static const char *ARMORS[] = {   /* lids: each one slows the cooling and adds a little DEF */
    "pot lid", "thermos sleeve", "cardboard lid", "colander helmet",
    "pair of oven mitts", "dignified apron"};
#define NARM ((int)(sizeof ARMORS / sizeof ARMORS[0]))

/* Monsters drop ingredients. G_RAT..G_OGRE follow the monster type order, so G_x comes from M_x. */
enum { G_RAT, G_SLIME, G_BAT, G_GOBLIN, G_DRAFT, G_CHAIR, G_SHROOM, G_MIMIC, G_OGRE, G_N };
enum { C_SPICY, C_FREEZE, C_N };
#define ING_DROP 40   /* percent chance a monster drops its ingredient */
static const char *ING_NAME[G_N] = {
    "rat tail", "slime jelly", "bat wing", "goblin pepper", "draft frost",
    "chair splinter", "spore cap", "mimic tooth", "ogre toenail"};
/* ingredients ride along on soup hits (splash and fling); see soup_riders() */
static const char *ING_SHORT[G_N] = {
    "soup hits heal, faster regen", "soup hits stick, 15% of hits split", "fling pierces, kills cost no time",
    "soup burns, +1 ATK, cools faster", "soup freezes, -1 ATK, cools slower", "unseen foes slow down",
    "soup hits confuse", "soup hits pay gold, extra loot", "soup hits knock back, +6 max HP"};
static const char *ING_DESC[G_N] = {
    "Each soup hit heals you 1 per tail, up to 3. Regenerates HP faster. Stacks.",
    "Soup hits leave the foe sticky for a turn. 15% of the damage you take splits off, up to 45%.",
    "Flings pierce on to the next foe in line. Killing something costs no time.",
    "Soup hits set the foe alight for 2 + peppers turns. +1 ATK for bonks. Soup cools faster per pepper.",
    "Soup hits freeze the foe for two turns. Soup cools 3 turns slower per frost. -1 ATK per frost, up to -2.",
    "Monsters you cannot see move slower. Stacks, up to 3.",
    "25% per cap, up to 60%, that soup hits confuse a foe into backing off.",
    "Each soup hit pays 2 gold per tooth, up to 3. 20% per tooth, up to 60%, that a kill drops extra loot.",
    "Soup hits knock the foe back a tile. Max HP +6 each, and heals as much."};
static const char *ING_USE[G_N] = {
    "The rat tail wiggles in the broth and whispers 'regeneration'.",
    "Slime jelly plops in. Your splashes will stick. Generously.",
    "The bat wing flaps dramatically. Flings will pierce. Kills will feel weightless.",
    "Goblin pepper! Your soup now sets things alight. The goblin accountant is alarmed.",
    "Draft frost chills the pot. Your soup freezes what it hits. Slow cooling, slow swinging.",
    "The splinter floats in. Unseen things get sleepy-slow.",
    "The spore cap sinks in and quietly ferments. Your soup will get weird.",
    "The mimic tooth clacks against the ladle. Soup hits now pay out.",
    "Ogre toenail! Chunky. Your soup now shoves. Max HP +6."};
static const struct { const char *name, *desc; int a, b; } COMBO[C_N] = {
    {"Spicy Spore Stew", "Burns last two turns longer, and spores confuse far more often. The peppers have opinions.", G_GOBLIN, G_SHROOM},
    {"Frozen Jelly Slushie", "Frost and jelly hold foes a turn longer. The frost no longer dulls your bonk.", G_DRAFT, G_SLIME},
};

enum { PK_ELBOWS, PK_SOCKS, PK_THERMOS, PK_FANGS, PK_BREAKFAST, PK_PENNIES, PK_NIGHT,
       PK_SPIKY, PK_NIMBLE, PK_HOTHANDS, PK_BARGAIN, PK_CRIT, PK_AURA, PK_POSTURE, PK_N };
static const char *PERK_NAME[PK_N] = {
    "Elbow Grease", "Thick Socks", "Thermos Lid", "Vampire Fangs", "Second Breakfast",
    "Lucky Pennies", "Night Vision", "Spiky Hair", "Nimble Toes", "Hot Hands",
    "Bargain Hunter", "Crit Fishing", "Know-It-All", "Good Posture"};
static const char *PERK_DESC[PK_N] = {
    "ATK +2. Bonks and soup hits hit harder.", "DEF +1", "Soup cools half as fast.", "Heal 2 HP per kill.", "Max HP +8.",
    "Double gold.", "See 3 tiles farther.", "Attackers take 1 damage.", "20% chance to dodge.",
    "Kills warm the soup twice as much. Stacks.", "Vending costs a third less.", "15% chance of triple damage.",
    "Identify all potions and scrolls.", "Regenerate 3x faster."};
static const int PK_STACK[PK_N] = {1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0};

enum { R_NORMAL, R_DARK, R_SLIPPERY, R_DISCO, R_GREEDY, R_BOUNCY, R_HAUNTED,
       R_BACKWARDS, R_COFFEE, R_CROWDED, R_DUCK, R_N };
static const char *RULE_NAME[R_N] = {
    "NORMAL", "DARK", "SLIPPERY", "DISCO", "GREEDY", "BOUNCY", "HAUNTED",
    "BACKWARDS", "COFFEE", "CROWDED", "DUCK"};
static const char *RULE_DESC[R_N] = {
    "This floor is aggressively normal.",
    "Someone forgot to pay the light bill.",
    "The floor is buttered. You slide.",
    "A disco ball spins. Everyone dances.",
    "Gold everywhere. Suspicious.",
    "Everything is bouncy. Hits knock foes back.",
    "It is drafty in here. Guard your soup.",
    "Left is right. Up is down. Good luck.",
    "The air smells like espresso. You are zippy.",
    "It is very crowded in here.",
    "You hear a distant, enormous HONK.",
};
static const Color RULE_COL[R_N] = {
    {200, 196, 190, 255}, {150, 120, 220, 255}, {252, 222, 110, 255}, {255, 110, 210, 255},
    {252, 200, 60, 255}, {130, 236, 120, 255}, {130, 214, 255, 255}, {255, 150, 90, 255},
    {200, 150, 100, 255}, {255, 110, 100, 255}, {252, 212, 78, 255}};
static const Color RULE_TINT[R_N] = {   /* light colour per rule; tiles live in TH[] */
    {255, 255, 255, 255}, {200, 196, 255, 255}, {214, 232, 255, 255}, {255, 238, 255, 255},
    {255, 236, 200, 255}, {240, 250, 240, 255}, {186, 212, 255, 255}, {255, 234, 240, 255},
    {255, 232, 206, 255}, {255, 236, 228, 255}, {222, 238, 255, 255}};

enum { S_PLAY, S_DEAD, S_WON, S_COLD, S_QUIT };

static const int DX[4] = {0, 0, -1, 1}, DY[4] = {-1, 1, 0, 0};

/* ============================================================ state === */

/* halfway kitchen upgrades: each is bought at most once per run (see the kitchen block below) */
enum { K_BIGPOT, K_COPPER, K_WIDE, K_LONG, K_LID, K_SPICE, K_SECOND, K_HEARTH, K_N };

static struct {
    int x, y, hp, maxhp, atk, def, lvl, xp, gold, soup, soup_tick;
    int confused, stunned, kills, turns, floor;
    int perk[PK_N];
    int ing[G_N], combo_seen;
    int ladle, lids;   /* soup gear: ladles add to the splash, lids slow the cooling */
    int kit[K_N];      /* kitchen upgrades bought this run */
} P;
static int aim;   /* 1 while waiting for a fling direction */

static struct { float x, y, flash, lunge, ldx, ldy, face; } PV;   /* player visuals */

static char tile[MH][MW];
static unsigned char seen[MH][MW], vis[MH][MW];
static int dist[MH][MW];
static float lightv[MH][MW];
static Room rooms[MAXROOMS];
static int nrooms;
static Mon mons[MAXMON];
static int nmon;
static Item items[MAXITEM];
static int nitem;
static Pocket inv[INVMAX];
static int ninv;
static char pot_name[NPOT][24], scr_name[NSCR][24];
static int pot_look[NPOT];
static int pot_known[NPOT], scr_known[NSCR];
static int rule = R_NORMAL, prev_rule = -1, state, pending_levels;
static char cause[96];
static char logm[LOGMAX][128];
static float logt[LOGMAX];
static int nlog;
static float g_time;

static void kill_mon(Mon *m);
static void check_level(void);
static void pickup(void);
static void compute_fov(void);
static void duck_phase(Mon *m);

/* The duck sheds at most one phase a hit: damage stops at the next phase's threshold, so even a huge
   dazed or crit splash leaves it in phase 2 or 3 rather than skipping the fight. */
static int duck_gate(const Mon *m, int d) {
    int floor_hp;
    if (m->t != M_DUCK) return d;
    floor_hp = m->hp * 3 > m->maxhp * 2 ? m->maxhp * 2 / 3 : m->hp * 3 > m->maxhp ? m->maxhp / 3 : 0;
    if (floor_hp > 0 && m->hp - d < floor_hp) d = m->hp - floor_hp;
    return d < 0 ? 0 : d;
}

static void msg(const char *f, ...) PRINTF_LIKE(1, 2);
static void msg(const char *f, ...) {
    va_list a;
    if (nlog == LOGMAX) {
        memmove(logm[0], logm[1], sizeof logm[0] * (LOGMAX - 1));
        memmove(logt, logt + 1, sizeof logt[0] * (LOGMAX - 1));
        nlog--;
    }
    va_start(a, f);
    vsnprintf(logm[nlog], sizeof logm[0], f, a);
    va_end(a);
    logm[nlog][0] = (char)toupper((unsigned char)logm[nlog][0]);
    logt[nlog] = g_time;
    nlog++;
}

/* ============================================================ sound === */

enum { SFX_HIT, SFX_HURT, SFX_COIN, SFX_PICK, SFX_DRINK, SFX_SCROLL, SFX_FIRE, SFX_LEVEL, SFX_STAIRS,
       SFX_HONK, SFX_DEATH, SFX_WIN, SFX_SPLAT, SFX_VEND, SFX_SELECT, SFX_STEP, SFX_N };
static Sound sfx[SFX_N];
static int audio_ok, muted;
static float master_vol = 0.7f;

static void play(int id) {
    if (!audio_ok || muted) return;
    SetSoundPitch(sfx[id], 0.92f + frand() * 0.16f);
    PlaySound(sfx[id]);
}

static float osc_sq(float ph) { return fmodf(ph, 1.0f) < 0.5f ? 1.0f : -1.0f; }
static float osc_saw(float ph) { return 2.0f * fmodf(ph, 1.0f) - 1.0f; }
static float osc_tri(float ph) { return 4.0f * fabsf(fmodf(ph, 1.0f) - 0.5f) - 1.0f; }

static Sound synth(int id) {
    static const float DUR[SFX_N] = {0.12f, 0.2f, 0.22f, 0.12f, 0.35f, 0.4f, 0.6f, 0.5f, 0.55f,
                                     0.55f, 0.9f, 1.1f, 0.18f, 0.25f, 0.05f, 0.04f};
    const int sr = 22050;
    int n = (int)(sr * DUR[id]), i;
    short *d = (short *)calloc((size_t)n, sizeof(short));
    float ph = 0, lp = 0;
    unsigned ns = 0x1234567u + (unsigned)id * 977u;
    Wave w;
    Sound s;
    for (i = 0; i < n; i++) {
        float t = (float)i / sr, p = (float)i / n, f, v = 0, env, nz;
        ns = ns * 1664525u + 1013904223u;
        nz = ((ns >> 9) & 0xFFFF) / 32768.0f - 1.0f;
        switch (id) {
        case SFX_HIT:
            f = 230 - 170 * p; ph += f / sr; env = (1 - p) * (1 - p);
            v = (nz * 0.5f + sinf(ph * 2 * PI_F) * 0.7f) * env;
            break;
        case SFX_HURT:
            f = 330 - 230 * p; ph += f / sr;
            v = osc_sq(ph) * 0.35f * (1 - p);
            break;
        case SFX_COIN:
            f = p < 0.3f ? 988 : 1319; ph += f / sr;
            v = osc_sq(ph) * 0.25f * (1 - p * 0.9f);
            break;
        case SFX_PICK:
            f = 520 + 760 * p; ph += f / sr;
            v = osc_tri(ph) * 0.45f * (1 - p);
            break;
        case SFX_DRINK:
            f = 260 + 180 * sinf(t * 70) + 260 * p; ph += f / sr;
            v = sinf(ph * 2 * PI_F) * 0.45f * (1 - p) * (0.55f + 0.45f * sinf(t * 45));
            break;
        case SFX_SCROLL:
            f = 1100 + 500 * p; ph += f / sr;
            lp += (nz - lp) * 0.25f;
            v = (lp * 0.5f + sinf(ph * 2 * PI_F) * 0.12f) * sinf(p * PI_F);
            break;
        case SFX_FIRE:
            lp += (nz - lp) * (0.08f + 0.2f * (1 - p));
            env = p < 0.05f ? p / 0.05f : (1 - p);
            v = lp * 1.6f * env;
            break;
        case SFX_LEVEL: {
            static const float notes[4] = {523, 659, 784, 1047};
            int k = (int)(p * 4);
            if (k > 3) k = 3;
            ph += notes[k] / sr;
            v = osc_sq(ph) * 0.22f * (1 - fmodf(p * 4, 1.0f) * 0.6f);
            break;
        }
        case SFX_STAIRS: {
            static const float notes[4] = {784, 659, 523, 392};
            int k = (int)(p * 4);
            if (k > 3) k = 3;
            ph += notes[k] / sr;
            v = osc_tri(ph) * 0.4f * (1 - fmodf(p * 4, 1.0f) * 0.7f);
            break;
        }
        case SFX_HONK:
            f = 205 + 18 * sinf(t * 38) - 45 * p; ph += f / sr;
            env = p < 0.04f ? p / 0.04f : p > 0.8f ? (1 - p) / 0.2f : 1;
            v = (osc_saw(ph) * 0.55f + osc_sq(ph * 0.5f) * 0.2f) * env;
            break;
        case SFX_DEATH:
            f = 440 * (1 - p * 0.75f); ph += f / sr;
            v = osc_tri(ph) * 0.45f * (1 - p);
            break;
        case SFX_WIN: {
            static const float notes[5] = {523, 659, 784, 1047, 1319};
            int k = (int)(p * 6);
            if (k > 4) k = 4;
            ph += notes[k] / sr;
            v = (osc_sq(ph) * 0.18f + osc_tri(ph * 0.5f) * 0.2f) * (k == 4 ? (1 - p) * 2.5f : 1.0f);
            break;
        }
        case SFX_SPLAT:
            lp += (nz - lp) * 0.15f;
            v = lp * 1.8f * (1 - p) * (1 - p);
            break;
        case SFX_VEND:
            f = 90 - 40 * p; ph += f / sr;
            v = (sinf(ph * 2 * PI_F) * 0.8f + nz * 0.3f * (p < 0.1f)) * (1 - p);
            break;
        case SFX_SELECT:
            ph += 880.0f / sr;
            v = osc_sq(ph) * 0.15f * (1 - p);
            break;
        case SFX_STEP:
            lp += (nz - lp) * 0.3f;
            v = lp * 0.35f * (1 - p);
            break;
        }
        v = clampf(v, -1, 1);
        d[i] = (short)(v * 30000);
    }
    w.frameCount = (unsigned)n;
    w.sampleRate = (unsigned)sr;
    w.sampleSize = 16;
    w.channels = 1;
    w.data = d;
    s = LoadSoundFromWave(w);
    free(d);
    return s;
}

/* =============================================================== fx === */

typedef struct { float x, y, vx, vy, life, max, size, grav; Color c; int add; } Part;
typedef struct { float x, y, t, max; char s[24]; Color c; int big; } FText;
typedef struct { int spr; float x, y, t, face, scale; Color tint; } FadeSpr;
typedef struct { float x, y, t; Color c; } Ring;
typedef struct { float x0, y0, x1, y1, t, dur, arc; int dx, dy, hit, dmg, tx, ty, band, wing; } Proj;   /* the flung soup, in flight */
typedef struct { float x, y, t; unsigned seed; Color c; } Splat;                           /* soup drying on the floor */
#define MAXPROJ 4
#define MAXSPLAT 10

static Color soup_col(int s);
static Color fx_band_col(int band);

static Part parts[MAXPART], uparts[MAXUPART];
static int npart, nupart;
static FText ftxt[MAXFTXT];
static int nftxt;
static FadeSpr fades[MAXFADE];
static int nfade;
static Ring rings[MAXRING];
static int nring;
static Proj projs[MAXPROJ];
static int nproj;
static Splat splats[MAXSPLAT];
static int nsplat;
static float slurp_t;   /* counts down while the bowl is raised for a slurp */
static float shake, hurt_flash, floor_fade, banner_t = 99;
static int fx_on = 1;         /* screen shake and flashes; the page turns them off for reduced motion */
static int cool_level = 1;    /* soup cooling: 0 cozy (slower), 1 normal, 2 scalding (faster) */

static float tcx(int tx) { return tx * TS + TS * 0.5f; }
static float tcy(int ty) { return ty * TS + TS * 0.5f; }

static void part_add(float x, float y, float vx, float vy, float life, float size, Color c, float grav, int add) {
    Part *p;
    if (npart >= MAXPART) return;
    p = &parts[npart++];
    p->x = x; p->y = y; p->vx = vx; p->vy = vy; p->life = life; p->max = life;
    p->size = size; p->c = c; p->grav = grav; p->add = add;
}

static void upart_add(float x, float y, float vx, float vy, float life, float size, Color c) {
    Part *p;
    if (nupart >= MAXUPART) return;
    p = &uparts[nupart++];
    p->x = x; p->y = y; p->vx = vx; p->vy = vy; p->life = life; p->max = life;
    p->size = size; p->c = c; p->grav = 0; p->add = 0;
}

static void fx_burst(int tx, int ty, int n, Color c, float speed, int add) {
    int i;
    for (i = 0; i < n; i++) {
        float a = frand() * 2 * PI_F, s = speed * (0.35f + 0.65f * frand());
        part_add(tcx(tx) + frange(-3, 3), tcy(ty) + frange(-3, 3), cosf(a) * s, sinf(a) * s - speed * 0.4f,
                 frange(0.35f, 0.8f), frange(1.0f, 2.5f), c, 160.0f, add);
    }
}

static void fx_sparkle(int tx, int ty, int n, Color c) {
    int i;
    for (i = 0; i < n; i++)
        part_add(tcx(tx) + frange(-6, 6), tcy(ty) + frange(-6, 6), frange(-8, 8), frange(-30, -10),
                 frange(0.5f, 1.0f), frange(1, 2), c, 0, 1);
}

static void fx_text(int tx, int ty, Color c, int big, const char *fmt, ...) PRINTF_LIKE(5, 6);
static void fx_text(int tx, int ty, Color c, int big, const char *fmt, ...) {
    FText *f;
    va_list a;
    if (nftxt >= MAXFTXT) {
        memmove(ftxt, ftxt + 1, sizeof ftxt[0] * (MAXFTXT - 1));
        nftxt--;
    }
    f = &ftxt[nftxt++];
    f->x = tcx(tx) + frange(-3, 3);
    f->y = (float)(ty * TS);
    f->t = 0;
    f->max = big ? 1.5f : 1.0f;
    f->c = c;
    f->big = big;
    va_start(a, fmt);
    vsnprintf(f->s, sizeof f->s, fmt, a);
    va_end(a);
}

static void fx_fade(int spr_id, float x, float y, float face, float scale, Color tint) {
    FadeSpr *f;
    if (nfade >= MAXFADE) return;
    f = &fades[nfade++];
    f->spr = spr_id; f->x = x; f->y = y; f->t = 0; f->face = face; f->scale = scale; f->tint = tint;
}

static void fx_ring(int tx, int ty, Color c) {
    if (nring >= MAXRING) return;
    rings[nring].x = tcx(tx);
    rings[nring].y = tcy(ty);
    rings[nring].t = 0;
    rings[nring].c = c;
    nring++;
}

static void fx_shake(float a) {
    if (fx_on && a > shake) shake = a;
}

/* a puff of steam: white, soft, drifting up */
static void fx_steam(float x, float y, int n) {
    int i;
    for (i = 0; i < n; i++)
        part_add(x + frange(-3, 3), y + frange(-2, 2), frange(-6, 6), frange(-26, -14), frange(0.6f, 1.1f),
                 frange(1.5f, 3), (Color){255, 248, 240, 130}, -8, 0);
}

/* the soup lands: chunks fly back off a wall or foe, a stain stays on the floor */
static void fx_splash(float x, float y, int dx, int dy, int hit, int band) {
    static const Color BITS[4] = {{240, 142, 56, 255}, {112, 190, 82, 255}, {232, 204, 148, 255}, {250, 120, 90, 255}};
    Color sc = fx_band_col(band);
    int i, n = fx_on ? (hit ? 16 : 11) : 5;
    for (i = 0; i < n; i++) {
        float a = atan2f((float)-dy, (float)-dx) + frange(-1.3f, 1.3f), sp = frange(30, 90);
        Color c = i % 3 == 2 ? BITS[(int)(frand() * 4) & 3] : sc;
        part_add(x, y, cosf(a) * sp, sinf(a) * sp - 20, frange(0.35f, 0.8f), frange(1, 2.6f), c, 170, 0);
    }
    if (fx_on && nring < MAXRING) {
        rings[nring].x = x; rings[nring].y = y; rings[nring].t = 0.35f;   /* a short, small ripple */
        rings[nring].c = alphac(sc, 0.7f);
        nring++;
    }
    if (nsplat >= MAXSPLAT) memmove(splats, splats + 1, sizeof splats[0] * (MAXSPLAT - 1)), nsplat--;
    splats[nsplat].x = x; splats[nsplat].y = y; splats[nsplat].t = 0;
    splats[nsplat].seed = (unsigned)(frand() * 100000); splats[nsplat].c = sc;
    nsplat++;
}

/* fling: a lob of soup from the player to its target. Without motion effects it just lands. */
static void fx_fling(int d, int steps, int wall, int hit, int dmg, int tx, int ty, int band) {
    float x0 = tcx(P.x), y0 = tcy(P.y) - 2, x1 = tcx(P.x + DX[d] * steps) + (wall ? DX[d] * 7 : 0);
    float y1 = tcy(P.y + DY[d] * steps) + (wall ? DY[d] * 7 : 0);
    Proj *q;
    if (!fx_on || nproj >= MAXPROJ) {
        fx_splash(x1, y1, DX[d], DY[d], hit, band);
        if (hit) fx_text(tx, ty, WHITE, 0, "%d", dmg);
        return;
    }
    q = &projs[nproj++];
    q->x0 = x0; q->y0 = y0; q->x1 = x1; q->y1 = y1; q->t = 0;
    q->dur = 0.12f + 0.04f * (float)steps; q->arc = 4 + 1.8f * (float)steps;
    q->dx = DX[d]; q->dy = DY[d]; q->hit = hit; q->dmg = dmg; q->tx = tx; q->ty = ty; q->band = band; q->wing = 0;
}

/* a bat wing carries the fling on from one foe's tile to the next (a pause, then a short flight) */
static void fx_fling_through(int x0, int y0, int x1, int y1, int band) {
    Proj *q;
    if (!fx_on || nproj >= MAXPROJ) {
        fx_splash(tcx(x1), tcy(y1), x1 > x0 ? 1 : x1 < x0 ? -1 : 0, y1 > y0 ? 1 : y1 < y0 ? -1 : 0, 1, band);
        return;
    }
    q = &projs[nproj++];
    q->x0 = tcx(x0); q->y0 = tcy(y0) - 2; q->x1 = tcx(x1); q->y1 = tcy(y1); q->t = -(0.12f + 0.04f * (float)(abs(x0 - P.x) + abs(y0 - P.y)));
    q->dur = 0.12f + 0.03f * (float)(abs(x1 - x0) + abs(y1 - y0)); q->arc = 3;
    q->dx = x1 > x0 ? 1 : x1 < x0 ? -1 : 0; q->dy = y1 > y0 ? 1 : y1 < y0 ? -1 : 0;
    q->hit = 1; q->dmg = 0; q->tx = x1; q->ty = y1; q->band = band; q->wing = 1;
}

static void lunge(float *lu, float *lx, float *ly, int dx, int dy) {
    *lu = 1;
    *lx = (float)dx;
    *ly = (float)dy;
}

/* ========================================================== helpers === */

static int inb(int x, int y) { return x >= 0 && y >= 0 && x < MW && y < MH; }
static int walkable(int x, int y) { return inb(x, y) && (tile[y][x] == '.' || tile[y][x] == '>'); }

static Mon *mon_at(int x, int y) {
    int i;
    for (i = 0; i < nmon; i++)
        if (mons[i].alive && mons[i].x == x && mons[i].y == y) return &mons[i];
    return NULL;
}

static Item *item_at(int x, int y) {
    int i;
    for (i = 0; i < nitem; i++)
        if (items[i].alive && items[i].x == x && items[i].y == y) return &items[i];
    return NULL;
}

static int free_at(int x, int y) { return walkable(x, y) && !mon_at(x, y) && !(x == P.x && y == P.y); }
static int mdist(const Mon *m) { return abs(m->x - P.x) + abs(m->y - P.y); }
static int need(int l) { return 5 * l * (l + 1); }

static const char *soup_word(int s) {
    /* the words follow the warmth bands: scalding 80+, hot 50-79, lukewarm 25-49, cold under 25 */
    return s >= 80 ? "piping hot" : s >= 65 ? "steaming" : s >= 50 ? "toasty" : s >= 35 ? "lukewarm"
         : s >= 25 ? "tepid" : s >= 10 ? "basically gazpacho" : s > 0 ? "nearly cold" : "cold";
}

static Color soup_col(int s) {
    Color hot = {244, 112, 40, 255}, warm = {236, 156, 60, 255}, luke = {206, 176, 110, 255};
    Color tep = {156, 164, 150, 255}, cold = {120, 150, 196, 255};
    float v = (float)s;
    if (v > 80) return mixc(warm, hot, (v - 80) / 20);
    if (v > 60) return mixc(luke, warm, (v - 60) / 20);
    if (v > 30) return mixc(tep, luke, (v - 30) / 30);
    return mixc(cold, tep, v / 30);
}

static void set_soup(int v) {
    static const int th[4] = {60, 40, 20, 10};
    static const char *tm[4] = {
        "Your soup is merely warm now.", "Your soup is lukewarm. Hurry.",
        "Your soup is basically gazpacho.", "Your soup is almost cold! Panic (calmly)."};
    int old = P.soup, i;
    if (v > 100) v = 100;
    if (v < 0) v = 0;
    P.soup = v;
    for (i = 0; i < 4; i++)
        if (old > th[i] && v <= th[i]) msg("%s", tm[i]);
    if (v <= 0 && state == S_PLAY) {
        state = S_COLD;
        play(SFX_DEATH);
    }
}

static void add_soup(int d) {
    set_soup(P.soup + d);
    if (d > 0) {
        int i;
        for (i = 0; i < 10; i++)
            part_add(PV.x * TS + TS / 2 + frange(-4, 4), PV.y * TS + 6, frange(-6, 6), frange(-40, -20),
                     frange(0.6f, 1.2f), frange(1.5f, 3), (Color){255, 240, 230, 160}, -10, 0);
    }
}

/* ==================================================== soup bands === */

/* The warmth at the moment of a soup attack picks its band. Scalding burns, hot hits plainly,
   lukewarm sticks and does less, cold barely does anything. */
enum { B_SCALD, B_HOT, B_LUKE, B_COLD, B_N };
static const char *BAND_NAME[B_N] = {"SCALDING", "HOT", "LUKEWARM", "COLD"};
static const char *BAND_ADJ[B_N] = {"scalding", "hot", "lukewarm", "cold"};
static const int BAND_PCT[B_N] = {110, 100, 60, 0};   /* soup damage as a percentage of the soup's power */
static const Color BAND_COL[B_N] = {{255, 120, 60, 255}, {240, 170, 80, 255}, {206, 190, 130, 255}, {120, 160, 220, 255}};

static int soup_band(int warmth) {
    return warmth >= 80 ? B_SCALD : warmth >= 50 ? B_HOT : warmth >= 25 ? B_LUKE : B_COLD;
}

/* ===================================================== puddles === */

/* Spilled soup stays where it lands, at the warmth it had (0 is no puddle), and cools 5 a turn.
   Hot puddles set whatever stands in them alight; lukewarm ones make it sticky. */
#define PUDDLE_HOT 50
#define PUDDLE_LUKE 25
static unsigned char puddle_heat[MH][MW];

/* hooks: warmth of the puddle on a tile (0 for none), and spilling soup onto a tile */
static int puddle_at(int x, int y) { return inb(x, y) ? puddle_heat[y][x] : 0; }
static void spill(int x, int y, int warmth) {
    Mon *m;
    if (!walkable(x, y)) return;
    if (warmth > 100) warmth = 100;
    if (warmth > puddle_heat[y][x]) puddle_heat[y][x] = (unsigned char)warmth;
    m = mon_at(x, y);
    if (m && m->t == M_MIMIC && !m->revealed) {   /* soup on a disguised mimic shows what it is */
        m->revealed = 1;
        m->awake = 1;
        msg("The soup splashes on the potion. It was a MIMIC!");
        fx_text(x, y, (Color){255, 80, 80, 255}, 1, "MIMIC!");
    }
}
static void puddles_cool(void) {
    int x, y;
    for (y = 0; y < MH; y++)
        for (x = 0; x < MW; x++)
            if (puddle_heat[y][x]) puddle_heat[y][x] = puddle_heat[y][x] > 5 ? (unsigned char)(puddle_heat[y][x] - 5) : 0;
}

/* monsters react to soup. The first time a reaction happens on a floor it is logged; after that it
   shows only as floating text. */
enum { RX_LICK, RX_DUCKLING_LICK, RX_FEED, RX_DRAFT_COOL, RX_DRAFT_HOT, RX_CHAIR_HOT, RX_DUCK_ANGRY, RX_N };
static unsigned react_said;   /* one bit per reaction already logged on this floor */
static int first_react(int rx) {
    unsigned bit = 1u << rx;
    if (react_said & bit) return 0;
    react_said |= bit;
    return 1;
}
#define LICK_RANGE 4      /* rats and ducklings notice a puddle this many tiles away */
#define BAT_DODGE 30      /* percent of flings a bat flaps out of the way of */
#define SLIME_MAX 24      /* soup grows a slime to this much max HP, and no further */

/* ================================================ soup fx: spawners === */
/* Art pass: purely visual. Splash waves, ladle bonks, status marks. Drawn by the soup fx drawing block in draw_world. */

static const Color FX_BAND[B_N] = {{255, 128, 40, 255}, {244, 166, 66, 255}, {214, 198, 150, 255}, {150, 160, 178, 255}};
static const Color FX_DARK[B_N] = {{150, 44, 20, 255}, {150, 84, 30, 255}, {120, 104, 70, 255}, {72, 82, 104, 255}};
#define MAXWAVE 3
#define MAXBONK 3
typedef struct { float x, y, t; int band; } SoupWave;
typedef struct { float x, y, t; int dx, dy; } SoupBonk;
static SoupWave waves[MAXWAVE];
static SoupBonk bonks[MAXBONK];
static int nwave, nbonk;
static unsigned char mon_frozen[MAXMON];   /* draft frost froze this monster (visual only; stuck is the rule) */
static int band_prev = -1;                 /* the HUD badge's last band, for its pulse */
static float band_pulse;

static Color fx_band_col(int band) { return FX_BAND[band]; }
static void fx_mark_frozen(const Mon *m) { mon_frozen[m - mons] = 1; }

/* A splash: soup bursts out to the eight tiles. Scalding is bright and steamy, hot warm, lukewarm
   gloopy and slow, cold a sad little dribble. Reduced motion gets a handful of droplets and no wave. */
static void fx_soup_wave(int tx, int ty, int band) {
    static const int AX[8] = {-1, 0, 1, -1, 1, -1, 0, 1}, AY[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
    static const float SPD[B_N] = {58, 50, 32, 16}, GRV[B_N] = {50, 130, 230, 280};
    int k, j, n = fx_on ? (band == B_COLD ? 1 : band == B_LUKE ? 3 : 4) : 1;
    Color c = FX_BAND[band];
    if (fx_on && nwave < MAXWAVE) {
        waves[nwave].x = tcx(tx); waves[nwave].y = tcy(ty); waves[nwave].t = 0; waves[nwave].band = band;
        nwave++;
    }
    for (k = 0; k < 8; k++) {
        float l = (AX[k] && AY[k]) ? 0.7071f : 1.0f;
        for (j = 0; j < n; j++) {
            float sp = SPD[band] * l * frange(0.7f, 1.2f);
            Color pc = j % 2 ? mixc(c, WHITE, 0.4f) : c;
            part_add(tcx(tx), tcy(ty), AX[k] * sp, AY[k] * sp - (band == B_COLD ? 4 : 18), band == B_LUKE ? frange(0.5f, 0.85f) : frange(0.35f, 0.7f),
                     band == B_LUKE ? frange(2, 3.4f) : band == B_COLD ? frange(1, 1.8f) : frange(1.4f, 2.6f), pc, GRV[band],
                     band == B_SCALD ? 1 : 0);
        }
    }
    if (band == B_SCALD) {
        fx_steam(tcx(tx), tcy(ty) - 6, fx_on ? 10 : 2);
        if (fx_on) fx_burst(tx, ty, 8, (Color){255, 220, 110, 255}, 55, 1);   /* sparks */
    } else if (band == B_HOT) {
        fx_steam(tcx(tx), tcy(ty) - 6, fx_on ? 5 : 1);
    }
    if (band == B_SCALD) fx_shake(5);
    else if (band == B_HOT) fx_shake(3.5f);
    else if (band == B_LUKE) fx_shake(2);
}

/* the ladle bonk: a short swing at the foe's tile */
static void fx_bonk(int tx, int ty) {
    int i;
    if (nbonk >= MAXBONK) {
        for (i = 1; i < MAXBONK; i++) bonks[i - 1] = bonks[i];
        nbonk--;
    }
    bonks[nbonk].x = tcx(tx); bonks[nbonk].y = tcy(ty); bonks[nbonk].t = 0;
    bonks[nbonk].dx = tx - P.x; bonks[nbonk].dy = ty - P.y;
    nbonk++;
}

/* ===================================================== cookbook === */

/* Discoveries for the page's cookbook. Each event is a kind and an index, queued as it happens and
   taken by the page through web_event() several times a second. The queue is kept on native builds
   too, so the gameplay harness can check it. */
enum { CB_TASTE = 1, CB_COMBO, CB_KILL, CB_RULE, CB_DUCK, CB_RUN, CB_TIP };
#define CB_QUEUE 256
static int cb_q[CB_QUEUE], cb_head, cb_tail;
static void cb_note(int kind, int index) {
    int next = (cb_tail + 1) % CB_QUEUE;
    if (next == cb_head) return;   /* full: nobody is listening, so drop it */
    cb_q[cb_tail] = kind * 100 + index;
    cb_tail = next;
}
static int cb_take(void) {
    int code;
    if (cb_head == cb_tail) return -1;
    code = cb_q[cb_head];
    cb_head = (cb_head + 1) % CB_QUEUE;
    return code;
}

/* ===================================================== tutorial tips === */

/* First-run tips. The first time a tip's condition holds in a run it is queued as cb_note(CB_TIP, id);
   the page shows each tip once per device (web/meta.js has the wording, in the same order). The bits are
   cleared when a run is set up, so a tip can come back in a later run. */
enum { TIP_START, TIP_MONSTER, TIP_SOUP_HALF, TIP_SOUP_LOW, TIP_ITEM, TIP_INGREDIENT, TIP_HP_LOW,
       TIP_FLING, TIP_STAIRS, TIP_VENDING, TIP_PUDDLE, TIP_N };
static unsigned tip_said;   /* one bit per tip already queued in this run */
static void tip_note(int id) {
    if (tip_said & (1u << id)) return;
    tip_said |= 1u << id;
    cb_note(CB_TIP, id);
}

/* ===================================================== soup recipe === */

/* kills warm the soup: 2 each, and every Hot Hands doubles that */
#define KILL_WARM 3
static int kill_warmth(void) { return KILL_WARM * (1 + P.perk[PK_HOTHANDS]); }

static int combo_on(int c) { return P.ing[COMBO[c].a] && P.ing[COMBO[c].b]; }

/* goblin pepper adds attack, draft frost takes up to 2 away (the frozen slushie keeps the swing) */
static int peppers(void) { return P.ing[G_GOBLIN] > 3 ? 3 : P.ing[G_GOBLIN]; }
static int ing_atk(void) {
    int frost = P.ing[G_DRAFT] > 2 ? 2 : P.ing[G_DRAFT];
    return peppers() - (combo_on(C_FREEZE) ? 0 : frost);
}
static int player_atk(void) { return P.atk + ing_atk(); }

static int soup_interval(void) {
    int n = (P.perk[PK_THERMOS] ? 16 : 8) + 3 * P.ing[G_DRAFT] - peppers() + 2 * P.lids + 2 * P.kit[K_COPPER];
    return n < 4 ? 4 : n;
}

static int regen_every(void) {
    int n = (P.perk[PK_POSTURE] ? 5 : 15) - 3 * P.ing[G_RAT];
    return n < 4 ? 4 : n;
}

static int spore_pct(void) {
    int pct = 25 * P.ing[G_SHROOM];
    if (pct > 60) pct = 60;
    return combo_on(C_SPICY) ? pct + 35 > 75 ? 75 : pct + 35 : pct;
}

static void spore_hit(Mon *m) {
    int pct = spore_pct();
    if (!pct || !m->alive || m->t == M_SHROOM || m->t == M_DRAFT || rnd(100) >= pct) return;
    m->fear = 3;
    msg("Spores! The %s gets dizzy and backs off.", MT[m->t].name);
    fx_text(m->x, m->y, (Color){220, 140, 240, 255}, 0, "dizzy");
}

static void add_ingredient(int g) {
    int c;
    cb_note(CB_TASTE, g);
    P.ing[g]++;
    if (g == G_OGRE) {
        P.maxhp += 6;
        P.hp += 6;
    }
    play(SFX_DRINK);
    msg("%s", ING_USE[g]);
    fx_text(P.x, P.y, MT[g].col, 0, "+%s", ING_NAME[g]);
    fx_sparkle(P.x, P.y, 16, MT[g].col);
    for (c = 0; c < C_N; c++) {
        if (!combo_on(c) || (P.combo_seen & (1 << c))) continue;
        P.combo_seen |= 1 << c;
        cb_note(CB_COMBO, c);
        msg("%s! %s", COMBO[c].name, COMBO[c].desc);
        fx_text(P.x, P.y, (Color){255, 220, 90, 255}, 1, "%s", COMBO[c].name);
        fx_sparkle(P.x, P.y, 30, (Color){255, 220, 90, 255});
    }
}

/* e.g. "2x rat tail, goblin pepper. Spicy Spore Stew"; empty when the soup is plain */
static void recipe_str(char *out, size_t n) {
    int g, c;
    char part[32];
    snprintf(out, n, "%s", "");
    for (g = 0; g < G_N; g++) {
        if (!P.ing[g]) continue;
        if (P.ing[g] > 1) snprintf(part, sizeof part, "%dx %s", P.ing[g], ING_NAME[g]);
        else snprintf(part, sizeof part, "%s", ING_NAME[g]);
        snprintf(out + strlen(out), n - strlen(out), "%s%s", out[0] ? ", " : "", part);
    }
    for (c = 0; c < C_N; c++)
        if (combo_on(c)) snprintf(out + strlen(out), n - strlen(out), "%s%s", out[0] ? ". " : "", COMBO[c].name);
}

static void die(const char *c) {
    if (state != S_PLAY) return;
    state = S_DEAD;
    snprintf(cause, sizeof cause, "%s", c);
    play(SFX_DEATH);
    fx_burst(P.x, P.y, 40, (Color){230, 60, 60, 255}, 90, 0);
    fx_shake(8);
}

static void pocket_name(Pocket p, char *out, size_t n, int full) {
    if (p.k == I_POTION) {
        if (pot_known[p.sub]) snprintf(out, n, full ? "potion of %s" : "%s", POT_EFF[p.sub]);
        else snprintf(out, n, full ? "%s potion" : "%s", pot_name[p.sub]);
    } else if (p.k == I_ROCK) {
        snprintf(out, n, "warm rock");
    } else if (p.k == I_INGREDIENT) {
        snprintf(out, n, full ? "%s (soup ingredient)" : "%s", ING_NAME[p.sub]);
    } else {
        if (scr_known[p.sub]) snprintf(out, n, full ? "scroll of %s" : "%s", SCR_EFF[p.sub]);
        else snprintf(out, n, full ? "scroll labeled %s" : "%s", scr_name[p.sub]);
    }
}

static void add_item(int k, int sub, int x, int y, int amt) {
    Item *it = NULL;
    int i;
    for (i = 0; i < nitem; i++)
        if (!items[i].alive) { it = &items[i]; break; }
    if (!it) {
        if (nitem >= MAXITEM) return;
        it = &items[nitem++];
    }
    it->k = k; it->sub = sub; it->x = x; it->y = y; it->amt = amt; it->alive = 1;
}

static Mon *spawn(int t, int x, int y) {
    Mon *m = NULL;
    int i, f = P.floor - 1;
    for (i = 0; i < nmon; i++)
        if (!mons[i].alive) { m = &mons[i]; break; }
    if (!m) {
        if (nmon >= MAXMON) return NULL;
        m = &mons[nmon++];
    }
    memset(m, 0, sizeof *m);
    m->t = t; m->x = x; m->y = y; m->alive = 1;
    m->hp = MT[t].hp + (t == M_DUCK || t == M_DUCKLING ? 0 : f);
    m->maxhp = m->hp;
    m->atk = MT[t].atk + (t == M_DUCK || t == M_DUCKLING ? 0 : f / 2);
    m->def = MT[t].def;
    m->revealed = t != M_MIMIC;
    if (t == M_MIMIC) m->look = rnd(NPOT);
    m->vx = (float)x;
    m->vy = (float)y;
    m->face = -1;
    m->bob = frand() * 6.28f;
    return m;
}

static void mon_moveto(Mon *m, int nx, int ny) {
    if (nx != m->x) m->face = nx > m->x ? 1.0f : -1.0f;
    m->x = nx;
    m->y = ny;
}

/* ===================================================== path + sight === */

static void bfs(void) {
    static int qx[MW * MH], qy[MW * MH];
    int h = 0, t = 0, x, y, d;
    for (y = 0; y < MH; y++)
        for (x = 0; x < MW; x++) dist[y][x] = FAR;
    dist[P.y][P.x] = 0;
    qx[t] = P.x; qy[t] = P.y; t++;
    while (h < t) {
        x = qx[h]; y = qy[h]; h++;
        for (d = 0; d < 4; d++) {
            int nx = x + DX[d], ny = y + DY[d];
            if (walkable(nx, ny) && dist[ny][nx] == FAR) {
                dist[ny][nx] = dist[y][x] + 1;
                qx[t] = nx; qy[t] = ny; t++;
            }
        }
    }
}

static int los(int x0, int y0, int x1, int y1) {
    int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
    int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1, err = dx + dy;
    for (;;) {
        int e2;
        if (x0 == x1 && y0 == y1) return 1;
        e2 = 2 * err;
        if (e2 >= dy) { err += dy; x0 += sx; }
        if (e2 <= dx) { err += dx; y0 += sy; }
        if (x0 == x1 && y0 == y1) return 1;
        if (tile[y0][x0] == '#') return 0;
    }
}

static int fov_radius(void) { return (rule == R_DARK ? 3 : 7) + (P.perk[PK_NIGHT] ? 3 : 0); }

static void compute_fov(void) {
    int r = fov_radius(), x, y;
    memset(vis, 0, sizeof vis);
    for (y = P.y - r; y <= P.y + r; y++)
        for (x = P.x - r; x <= P.x + r; x++) {
            int dx = x - P.x, dy = y - P.y;
            if (!inb(x, y) || dx * dx + dy * dy > r * r + r) continue;
            if (los(P.x, P.y, x, y)) vis[y][x] = seen[y][x] = 1;
        }
}

/* move one step down (away=0) or up (away=1) the distance map */
static int step(Mon *m, int away) {
    int best = -1, bd = dist[m->y][m->x], ties = 0, d;
    for (d = 0; d < 4; d++) {
        int nx = m->x + DX[d], ny = m->y + DY[d], v;
        if (!free_at(nx, ny)) continue;
        v = dist[ny][nx];
        if (v == FAR) continue;
        if (away ? v > bd : v < bd) { bd = v; best = d; ties = 1; }
        else if (best >= 0 && v == bd && rnd(++ties) == 0) best = d;
    }
    if (best < 0) return 0;
    mon_moveto(m, m->x + DX[best], m->y + DY[best]);
    return 1;
}

static void wander(Mon *m) {
    int d = rnd(4), nx = m->x + DX[d], ny = m->y + DY[d];
    if (free_at(nx, ny)) mon_moveto(m, nx, ny);
}

/* drafts drift through walls */
static void ghost_step(Mon *m) {
    int ax = abs(P.x - m->x), ay = abs(P.y - m->y), nx = m->x, ny = m->y;
    if (ax > ay || (ax == ay && rnd(2))) nx += (P.x > m->x) - (P.x < m->x);
    else ny += (P.y > m->y) - (P.y < m->y);
    if (nx < 1 || ny < 1 || nx > MW - 2 || ny > MH - 2) return;
    if (mon_at(nx, ny) || (nx == P.x && ny == P.y)) return;
    mon_moveto(m, nx, ny);
    if (puddle_at(nx, ny)) {   /* a draft cools the soup it drifts over */
        int h = puddle_heat[ny][nx];
        puddle_heat[ny][nx] = h > 15 ? (unsigned char)(h - 15) : 0;
        if (first_react(RX_DRAFT_COOL)) msg("The chilly draft drifts over the spilled soup. It cools right down.");
        fx_text(nx, ny, (Color){140, 220, 255, 255}, 0, "brr");
    }
}

/* one step closer to (tx, ty) over free tiles; returns 1 if it moved */
static int step_to(Mon *m, int tx, int ty) {
    int d, best = -1, bd = abs(m->x - tx) + abs(m->y - ty);
    for (d = 0; d < 4; d++) {
        int nx = m->x + DX[d], ny = m->y + DY[d], nd;
        if (!free_at(nx, ny)) continue;
        nd = abs(nx - tx) + abs(ny - ty);
        if (nd < bd) { bd = nd; best = d; }
    }
    if (best < 0) return 0;
    mon_moveto(m, m->x + DX[best], m->y + DY[best]);
    return 1;
}

/* rats and ducklings go for spilled soup: a puddle within LICK_RANGE pulls them off, even when you are
   next to them (two times in three). Standing in one they lick for three turns and ignore you.
   Returns 1 if the turn went to the soup. */
static int lick_act(Mon *m, int md) {
    int x, y, tx = 0, ty = 0, bd = LICK_RANGE + 1;
    if (m->lick > 0) { m->lick--; return 1; }
    for (y = m->y - LICK_RANGE; y <= m->y + LICK_RANGE; y++)
        for (x = m->x - LICK_RANGE; x <= m->x + LICK_RANGE; x++) {
            int dd = abs(x - m->x) + abs(y - m->y);
            if (dd < bd && puddle_at(x, y)) { bd = dd; tx = x; ty = y; }
        }
    if (bd > LICK_RANGE) return 0;
    if (md == 1 && rnd(3) == 0) return 0;
    if (bd == 0) {
        m->lick = 2;
        if (m->t == M_RAT ? first_react(RX_LICK) : first_react(RX_DUCKLING_LICK))
            msg("The %s finds the spilled soup and starts licking. It is not listening to you.", MT[m->t].name);
        fx_text(m->x, m->y - 1, (Color){255, 200, 140, 255}, 0, "slurp");
        return 1;
    }
    return step_to(m, tx, ty);
}

/* ======================================================= generation === */

static void carve_h(int x0, int x1, int y) {
    int x, lo = x0 < x1 ? x0 : x1, hi = x0 < x1 ? x1 : x0;
    for (x = lo; x <= hi; x++) tile[y][x] = '.';
}

static void carve_v(int y0, int y1, int x) {
    int y, lo = y0 < y1 ? y0 : y1, hi = y0 < y1 ? y1 : y0;
    for (y = lo; y <= hi; y++) tile[y][x] = '.';
}

static int room_spot(int ri, int *ox, int *oy) {
    Room *r = &rooms[ri];
    int k;
    for (k = 0; k < 60; k++) {
        int x = r->x + rnd(r->w), y = r->y + rnd(r->h);
        if (tile[y][x] == '.' && free_at(x, y) && !item_at(x, y)) { *ox = x; *oy = y; return 1; }
    }
    return 0;
}

static void place_item(int k, int sub, int amt) {
    int x, y;
    if (room_spot(rnd(nrooms), &x, &y)) add_item(k, sub, x, y, amt);
}

static int count_reachable(void) {
    int x, y, c = 0;
    bfs();
    for (y = 0; y < MH; y++)
        for (x = 0; x < MW; x++) c += dist[y][x] != FAR;
    return c;
}

/* ============================================= route stairs + vending === */

/* Floors 1-6 have two ways down. Each stair remembers the rule of the floor it leads to. */
static int nstairs, stair_x[2], stair_y[2], stair_rule[2], stair_hinted[2];
static int next_rule = -1;   /* set by the stair you take, read by the next gen_floor */

typedef struct { int k, sub, price, moody, sold; } Offer;
static Offer offers[3];
static int vend_x = -1, vend_y, vend_open;   /* vend_x < 0: no machine on this floor */

static void add_stair(int x, int y) {
    tile[y][x] = '>';
    stair_x[nstairs] = x;
    stair_y[nstairs] = y;
    stair_hinted[nstairs] = 0;
    nstairs++;
}

static int stair_at(int x, int y) {
    int i;
    for (i = 0; i < nstairs; i++)
        if (stair_x[i] == x && stair_y[i] == y) return i;
    return -1;
}

/* stepping near a stair names the floor it leads to, once per stair */
static void stair_hints(void) {
    int i;
    for (i = 0; i < nstairs; i++) {
        if (stair_hinted[i] || abs(stair_x[i] - P.x) > 2 || abs(stair_y[i] - P.y) > 2) continue;
        stair_hinted[i] = 1;
        msg("Stairs ahead lead to the %s floor.", RULE_NAME[stair_rule[i]]);
    }
}

/* a rule for the next floor that is neither this floor's rule nor the other option */
static int other_rule(int avoid, int avoid2) {
    int r;
    do r = rnd(R_DUCK); while (r == avoid || r == avoid2);
    return r;
}

static void roll_offers(int moody_chance) {
    int i;
    for (i = 0; i < 3; i++) {
        Offer *o = &offers[i];
        o->k = rnd(2) ? I_POTION : I_SCROLL;
        o->sub = rnd(o->k == I_POTION ? NPOT : NSCR);
        o->price = o->k == I_POTION ? 8 + rnd(5) : 14 + rnd(7);
        o->moody = 0;
        o->sold = 0;
    }
    if (rnd(100) < moody_chance) {   /* the machine is in a mood: one cheap offer, a mystery */
        offers[2].moody = 1;
        offers[2].price = 5;
    }
}

static int offer_price(const Offer *o) { return P.perk[PK_BARGAIN] ? o->price * 2 / 3 : o->price; }

static void place_vending(void) {
    int before = count_reachable(), k, x, y;
    for (k = 0; k < 20; k++) {
        if (!room_spot(1 + rnd(nrooms - 1), &x, &y)) continue;
        tile[y][x] = 'V';
        if (count_reachable() == before - 1) {   /* blocks nothing */
            vend_x = x;
            vend_y = y;
            roll_offers(15);
            return;
        }
        tile[y][x] = '.';
    }
}

static int pick_mon(void) {
    int w[M_N], tot = 0, t, r;
    for (t = 0; t < M_N; t++) {
        w[t] = MT[t].minf <= P.floor ? MT[t].weight : 0;
        if (t == M_RAT && w[t]) w[t] = P.floor < 8 ? 10 - P.floor : 2;
        if (rule == R_GREEDY && t == M_GOBLIN) w[t] *= 3;
        if (rule == R_HAUNTED && t == M_DRAFT) w[t] *= 5;
        tot += w[t];
    }
    r = rnd(tot);
    for (t = 0; t < M_N; t++) {
        if (r < w[t]) return t;
        r -= w[t];
    }
    return M_RAT;
}

static void snap_visuals(void);

static void gen_floor(void) {
    int i, x, y, n, sx, sy;
    Room *last;

    memset(puddle_heat, 0, sizeof puddle_heat);   /* puddles stay on their floor */

    /* each floor comes from its own seed, so a seed always makes the same dungeon */
    rs_gen = mix64(g_seed * 1000003ULL + (unsigned long long)P.floor * 7919ULL);
    rsp = &rs_gen;

    do {
        memset(tile, '#', sizeof tile);
        nrooms = 0;
        for (i = 0; i < 400 && nrooms < MAXROOMS; i++) {
            Room r;
            int j, ok = 1;
            r.w = 4 + rnd(9);
            r.h = 3 + rnd(4);
            r.x = 1 + rnd(MW - r.w - 2);
            r.y = 1 + rnd(MH - r.h - 2);
            for (j = 0; j < nrooms && ok; j++) {
                Room *q = &rooms[j];
                if (r.x <= q->x + q->w && q->x <= r.x + r.w && r.y <= q->y + q->h && q->y <= r.y + r.h) ok = 0;
            }
            if (!ok) continue;
            for (y = r.y; y < r.y + r.h; y++)
                for (x = r.x; x < r.x + r.w; x++) tile[y][x] = '.';
            if (nrooms > 0) {
                Room *p = &rooms[nrooms - 1];
                int px = p->x + p->w / 2, py = p->y + p->h / 2, cx = r.x + r.w / 2, cy = r.y + r.h / 2;
                if (rnd(2)) { carve_h(px, cx, py); carve_v(py, cy, cx); }
                else        { carve_v(py, cy, px); carve_h(px, cx, cy); }
            }
            rooms[nrooms++] = r;
        }
    } while (nrooms < 5);

    memset(seen, 0, sizeof seen);
    nmon = 0;
    nitem = 0;
    react_said = 0;
    P.x = rooms[0].x + rooms[0].w / 2;
    P.y = rooms[0].y + rooms[0].h / 2;
    last = &rooms[nrooms - 1];
    sx = last->x + last->w / 2;
    sy = last->y + last->h / 2;
    nstairs = 0;
    vend_x = -1;
    if (P.floor < FLOORS) add_stair(sx, sy);
    if (P.floor < FLOORS - 1)   /* a second way down, in another room */
        for (i = 0; i < 20 && nstairs < 2; i++)
            if (room_spot(1 + rnd(nrooms - 2), &x, &y)) add_stair(x, y);

    if (P.floor == 1) rule = R_NORMAL;
    else if (P.floor >= FLOORS) rule = R_DUCK;
    else if (next_rule >= 0) rule = next_rule;
    else { do rule = rnd(R_DUCK); while (rule == prev_rule); }
    next_rule = -1;
    prev_rule = rule;
    /* the stairs preview the next floor: two different rules, neither repeating this one */
    for (i = 0; i < nstairs; i++)
        stair_rule[i] = P.floor == FLOORS - 1 ? R_DUCK : other_rule(rule, i ? stair_rule[0] : -1);

    if (P.floor > 1 && P.floor < FLOORS && rnd(100) < 55) place_vending();
    if (P.floor >= FLOORS) spawn(M_DUCK, sx, sy);

    n = P.floor >= FLOORS ? 4 : 3 + P.floor;
    if (rule == R_CROWDED) n *= 2;
    for (i = 0; i < n; i++)
        if (room_spot(1 + rnd(nrooms - 1), &x, &y)) spawn(pick_mon(), x, y);

    n = 3 + rnd(3);
    if (rule == R_GREEDY) n *= 3;
    for (i = 0; i < n; i++) place_item(I_GOLD, 0, 3 + rnd(8) + P.floor * 2);
    n = 1 + rnd(2);
    for (i = 0; i < n; i++) place_item(I_POTION, rnd(NPOT), 0);
    n = 1 + rnd(2);
    for (i = 0; i < n; i++) place_item(I_SCROLL, rnd(NSCR), 0);
    n = 1 + rnd(2);
    for (i = 0; i < n; i++) place_item(I_ROCK, 0, 0);
    if (rnd(100) < 40) place_item(I_WEAPON, rnd(NWEAP), 0);
    if (rnd(100) < 35) place_item(I_ARMOR, rnd(NARM), 0);

    rsp = &rs_game;
    snap_visuals();
}

/* =========================================================== combat === */

/* ================================================ monster statuses === */

/* Burning hurts burn_dmg a turn for the turns left; stuck skips turns. Both only grow until they run out. */
/* draft frost never catches fire; haunted furniture neither burns nor sticks */
static int can_burn(const Mon *m) { return m->t != M_DRAFT && m->t != M_CHAIR; }
static int can_stick(const Mon *m) { return m->t != M_CHAIR; }
static void set_burn(Mon *m, int turns, int dmg) {
    if (!can_burn(m)) return;
    if (turns > m->burn) m->burn = turns;
    if (dmg > m->burn_dmg) m->burn_dmg = dmg;
}
static void set_stuck(Mon *m, int turns) {
    if (!can_stick(m)) return;
    if (turns > m->stuck) m->stuck = turns;
}

/* hook for monsters_act: a stuck monster loses this turn; returns 1 if it did */
static int mon_stuck_turn(Mon *m) {
    if (m->stuck <= 0) return 0;
    m->stuck--;
    return 1;
}

/* a slime sitting in hot soup drinks it: one more max HP a turn */
static void slime_feed(Mon *m) {
    if (m->maxhp >= SLIME_MAX) return;
    m->maxhp++;
    m->hp++;
    if (first_react(RX_FEED)) msg("The slime drinks the hot soup. It is getting bigger.");
    fx_text(m->x, m->y, BAND_COL[B_HOT], 0, "+1");
}

/* once a turn, before the monsters move: hot puddles set what stands in them alight (slimes drink them
   instead), lukewarm ones make it sticky, and burning monsters take their damage. Bats fly over puddles. */
static void mon_status_tick(void) {
    int i;
    for (i = 0; i < nmon && state == S_PLAY; i++) {
        Mon *m = &mons[i];
        int heat;
        if (!m->alive) continue;
        heat = m->t == M_BAT ? 0 : puddle_at(m->x, m->y);
        if (heat >= PUDDLE_HOT && m->t == M_SLIME) {
            slime_feed(m);
        } else if (heat >= PUDDLE_HOT && can_burn(m)) {
            if (!m->burn) msg("The %s stands in hot soup. It catches fire.", MT[m->t].name);
            set_burn(m, 2, 1);
        } else if (heat >= PUDDLE_HOT) {
            if (first_react(m->t == M_DRAFT ? RX_DRAFT_HOT : RX_CHAIR_HOT))
                msg(m->t == M_DRAFT ? "The chilly draft laughs at the hot soup. Drafts cannot be cooked."
                                    : "The haunted chair sits in the hot soup, unbothered. It is furniture.");
        } else if (heat >= PUDDLE_LUKE) {
            set_stuck(m, 1);
        }
        if (m->burn <= 0) continue;
        m->burn--;
        m->hp -= duck_gate(m, m->burn_dmg);
        if (m->t == M_DUCK && m->hp > 0) duck_phase(m);
        m->revealed = 1;
        m->awake = 1;
        m->flash = 1;
        fx_text(m->x, m->y, (Color){255, 140, 60, 255}, 0, "-%d", m->burn_dmg);
        if (fx_on) fx_burst(m->x, m->y, 4, (Color){255, 150, 60, 255}, 30, 1);
        if (m->hp <= 0) {
            msg("The %s burns up. Crispy.", MT[m->t].name);
            kill_mon(m);
            continue;
        }
        if (m->burn <= 0) m->burn_dmg = 0;
    }
}

/* goblins go for a pocketed ingredient before any gold, then run off with it. Killing one drops it back. */
static int goblin_pocket(Mon *m) {
    int i;
    for (i = 0; i < ninv; i++) {
        if (inv[i].k != I_INGREDIENT) continue;
        m->stole = inv[i].sub + 1;
        msg("The goblin accountant lifts your %s out of your pocket! It runs.", ING_NAME[inv[i].sub]);
        fx_text(P.x, P.y, (Color){112, 190, 82, 255}, 0, "-%s", ING_NAME[inv[i].sub]);
        memmove(&inv[i], &inv[i + 1], sizeof(Pocket) * (size_t)(ninv - i - 1));
        ninv--;
        return 1;
    }
    return 0;
}

/* a Cast-Iron Lid turns one point off every hit you take, but a hit still lands for at least 1 */
static int lid_cut(int d) { return P.kit[K_LID] && d > 1 ? d - 1 : d; }

static void mon_attack(Mon *m) {
    const MonType *T = &MT[m->t];
    int d;
    lunge(&m->lunge, &m->ldx, &m->ldy, P.x - m->x, P.y - m->y);
    if (P.x != m->x) m->face = P.x > m->x ? 1.0f : -1.0f;
    if (m->t == M_DUCK && m->phase >= 3 && rnd(100) < 35) {   /* the slurp: steal warmth, heal a little */
        add_soup(-10);
        m->hp += 4;
        if (m->hp > m->maxhp) m->hp = m->maxhp;
        msg("The duck SLURPS your soup. -10 warmth. It looks refreshed.");
        fx_text(P.x, P.y, (Color){140, 220, 255, 255}, 0, "-10 soup");
        fx_burst(P.x, P.y, 12, (Color){190, 236, 255, 255}, 50, 1);
        play(SFX_SCROLL);
        return;
    }
    if (P.perk[PK_NIMBLE] && rnd(100) < 20) {
        msg("You nimbly dodge the %s.", T->name);
        fx_text(P.x, P.y, (Color){200, 240, 255, 255}, 0, "dodge!");
        return;
    }
    if (m->t == M_DRAFT) {
        d = 6 + P.floor / 2;
        msg("The chilly draft blows on your soup. -%d warmth.", d);
        fx_text(P.x, P.y, (Color){140, 220, 255, 255}, 0, "-%d soup", d);
        fx_burst(P.x, P.y, 14, (Color){190, 236, 255, 255}, 50, 1);
        play(SFX_SCROLL);
        add_soup(-d);
        return;
    }
    if (m->t == M_GOBLIN && !m->gold && !m->stole && goblin_pocket(m)) return;
    if (m->t == M_GOBLIN && !m->gold && !m->stole && P.gold > 0) {
        int i;
        d = 5 + rnd(11) + P.floor * 2;
        if (d > P.gold) d = P.gold;
        P.gold -= d;
        m->gold = d;
        msg("The goblin accountant audits %d gold out of your pockets!", d);
        fx_text(P.x, P.y, (Color){252, 212, 78, 255}, 0, "-%dg", d);
        for (i = 0; i < 10; i++)
            part_add(tcx(P.x), tcy(P.y), (m->x - P.x) * frange(40, 90) + frange(-20, 20), frange(-70, -30),
                     0.6f, 2, (Color){252, 212, 78, 255}, 200, 0);
        play(SFX_COIN);
        return;
    }
    d = m->atk - P.def + rnd(3) - 1;
    if (d < 1) d = 1;
    if (m->t == M_OGRE && m->fury) d = d * 3 / 2;   /* a furious ogre hits harder */
    if (P.ing[G_SLIME]) {
        int pct = 15 * P.ing[G_SLIME], cut = d * (pct > 45 ? 45 : pct) / 100;
        if (cut > 0) {
            d -= cut;
            msg("The slime jelly takes %d of the blow.", cut);
        }
    }
    d = lid_cut(d);
    P.hp -= d;
    PV.flash = 1;
    hurt_flash = fx_on ? 0.55f : 0;
    fx_shake(3.5f);
    fx_text(P.x, P.y, (Color){255, 90, 80, 255}, 0, "-%d", d);
    fx_burst(P.x, P.y, 6, (Color){230, 60, 60, 255}, 50, 0);
    play(SFX_HURT);
    msg("The %s %s you for %d.", T->name, T->verb, d);
    if (m->t == M_SHROOM) {
        P.confused = 4;
        msg("Spores! Everything is wobbly.");
        fx_burst(P.x, P.y, 18, (Color){220, 140, 240, 255}, 40, 1);
    }
    if (P.perk[PK_SPIKY] && m->alive) {
        m->hp -= duck_gate(m, P.perk[PK_SPIKY]);
        if (m->t == M_DUCK && m->hp > 0) duck_phase(m);
        m->flash = 1;
        if (m->hp <= 0) { msg("Your spiky hair finishes it off."); kill_mon(m); }
    }
    if (P.hp <= 0) {
        char b[96];
        snprintf(b, sizeof b, "%s %s", T->art, T->name);
        die(b);
    }
}

static void kill_mon(Mon *m) {
    const MonType *T = &MT[m->t];
    m->alive = 0;
    P.kills++;
    P.xp += T->xp;
    fx_fade(m->t == M_MIMIC && !m->revealed ? SP_POTION : T->spr, m->vx, m->vy, m->face,
            m->t == M_DUCK ? 2.0f : 1.0f, WHITE);
    fx_burst(m->x, m->y, m->t == M_DUCK ? 80 : 22, T->col, m->t == M_DUCK ? 140.0f : 80.0f, 0);
    if (m->t == M_DUCK) {
        cb_note(CB_DUCK, 0);
        state = S_WON;
        msg("The VERY LARGE DUCK falls. honk... You take the Golden Spoon.");
        fx_sparkle(m->x, m->y, 60, (Color){255, 220, 90, 255});
        fx_shake(10);
        play(SFX_WIN);
        return;
    }
    cb_note(CB_KILL, m->t);
    play(m->t == M_SLIME ? SFX_SPLAT : SFX_HIT);
    msg("%s", T->death);
    if (m->gold) {
        add_item(I_GOLD, 0, m->x, m->y, m->gold);
        msg("It drops %d gold. Your gold, technically.", m->gold);
    }
    if (m->stole) {
        add_item(I_INGREDIENT, m->stole - 1, m->x, m->y, 1);
        msg("It drops your %s. Accounting is a hard job.", ING_NAME[m->stole - 1]);
    }
    if (rnd(100) < ING_DROP) {
        add_item(I_INGREDIENT, m->t, m->x, m->y, 1);
        msg("It drops a %s. Soup-worthy.", ING_NAME[m->t]);
    }
    if (P.ing[G_MIMIC]) {
        int pct = 20 * P.ing[G_MIMIC], potion;
        if (rnd(100) < (pct > 60 ? 60 : pct)) {
            potion = rnd(2);
            add_item(potion ? I_POTION : I_SCROLL, rnd(potion ? NPOT : NSCR), m->x, m->y, 0);
            msg("The mimic tooth rattles. Something else falls out.");
        }
    }
    if (P.perk[PK_FANGS]) {
        P.hp += 2 * P.perk[PK_FANGS];
        if (P.hp > P.maxhp) P.hp = P.maxhp;
        fx_text(P.x, P.y, (Color){120, 240, 120, 255}, 0, "+%d", 2 * P.perk[PK_FANGS]);
    }
    add_soup(kill_warmth());
    fx_text(P.x, P.y - 1, (Color){255, 200, 140, 255}, 0, "+%d soup", kill_warmth());
    check_level();
}

/* ============================================================ the duck === */

static const char *PHASE_BIG[] = {"", "", "PHASE 2: WADDLE", "PHASE 3: SLURP"};
static const char *PHASE_SMALL[] = {
    "", "",
    "The duck sheds its polite face. Ducklings! Watch the tail feathers.",
    "It wants your soup now. Its honks come faster. Hit it while it is dazed."};
static int phase_banner;   /* 2 or 3 while its banner shows */
static float phase_t = 99;

static void summon_ducklings(Mon *m) {
    static const int AX[8] = {-1, 0, 1, -1, 1, -1, 0, 1}, AY[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
    int k, want = 2 + rnd(2), s = rnd(8);
    for (k = 0; k < 8 && want > 0; k++) {
        int d = (s + k) % 8, nx = m->x + AX[d], ny = m->y + AY[d];
        Mon *c;
        if (!free_at(nx, ny)) continue;
        c = spawn(M_DUCKLING, nx, ny);
        if (!c) break;
        c->awake = 1;
        want--;
    }
}

/* the duck's hp third sets its phase: over 66% is 1, over 33% is 2, otherwise 3 */
static void duck_phase(Mon *m) {
    int ph = m->hp * 3 > m->maxhp * 2 ? 1 : m->hp * 3 > m->maxhp ? 2 : 3;
    if (ph == 1 || ph <= m->phase) return;
    m->phase = ph;
    phase_banner = ph;
    phase_t = 0;
    fx_shake(10);
    fx_ring(m->x, m->y, (Color){255, 230, 120, 255});
    play(SFX_HONK);
    if (ph == 2) {
        summon_ducklings(m);
        msg("The duck sheds its polite face. HONK! Ducklings, to me!");
        fx_text(m->x, m->y - 1, (Color){255, 230, 90, 255}, 1, "PHASE 2!");
    } else {
        msg("The duck starts SLURPING. Your soup is in danger. Its honks come faster.");
        fx_text(m->x, m->y - 1, (Color){255, 230, 90, 255}, 1, "PHASE 3!");
    }
}

static void duck_barrel(Mon *m) {
    int d;
    lunge(&m->lunge, &m->ldx, &m->ldy, P.x - m->x, P.y - m->y);
    if (P.perk[PK_NIMBLE] && rnd(100) < 20) {
        msg("You nimbly dodge the charging duck.");
        fx_text(P.x, P.y, (Color){200, 240, 255, 255}, 0, "dodge!");
        return;
    }
    d = 2 * m->atk + 2 - P.def + rnd(3) - 1;
    if (d < 1) d = 1;
    d = lid_cut(d);
    P.hp -= d;
    PV.flash = 1;
    hurt_flash = 0.55f;
    fx_shake(6);
    fx_text(P.x, P.y, (Color){255, 90, 80, 255}, 0, "-%d", d);
    fx_burst(P.x, P.y, 12, (Color){230, 60, 60, 255}, 60, 0);
    play(SFX_HURT);
    msg("The duck barrels into you for %d. Feathers everywhere.", d);
    if (P.hp <= 0) die("the VERY LARGE DUCK");
}

/* the waddle charge: up to six tiles, stopping at you, a wall (dazed) or a sibling */
static void duck_rush(Mon *m) {
    int k;
    for (k = 0; k < 6; k++) {
        int nx = m->x + m->cdx, ny = m->y + m->cdy;
        if (nx == P.x && ny == P.y) { duck_barrel(m); return; }
        if (!walkable(nx, ny)) {
            m->cstun = 2;
            msg("The duck charges into the wall. The wall wins. The duck is dazed. HIT IT.");
            fx_shake(8);
            fx_text(nx, ny, (Color){255, 230, 90, 255}, 0, "OW");
            return;
        }
        if (mon_at(nx, ny)) { msg("The duck charges into its own ducklings. Awkward."); return; }
        mon_moveto(m, nx, ny);
    }
    msg("The duck waddles past, miffed. Not done yet.");
}

/* the duck's own turn in phases 2 and 3: stunned, telegraphing or charging. Returns 1 if it used the turn. */
static int duck_act(Mon *m, int md) {
    int ox = P.x - m->x, oy = P.y - m->y;
    if (m->fury > 0) m->fury--;
    if (puddle_at(m->x, m->y) >= PUDDLE_HOT) {   /* hot soup makes the duck angry: fast, and charging again at once */
        if (!m->fury) {
            if (first_react(RX_DUCK_ANGRY)) msg("The duck sits in the hot soup. It is ANGRY, and it is fast now.");
            fx_text(m->x, m->y - 1, (Color){255, 120, 90, 255}, 0, "ANGRY");
        }
        m->fury = 3;
        m->ccd = 0;
    }
    if (m->cstun > 0) { m->cstun--; return 1; }
    if (m->ccd > 0) m->ccd--;
    if (m->phase < 2) return 0;
    if (m->tele) {
        m->tele = 0;
        duck_rush(m);
        return 1;
    }
    if (m->ccd || md < 2 || md > 6 || (ox && oy)) return 0;
    m->cdx = (ox > 0) - (ox < 0);
    m->cdy = (oy > 0) - (oy < 0);
    m->tele = 1;
    m->ccd = 5;
    msg("The VERY LARGE DUCK wiggles its tailfeathers. WADDLE CHARGE! Step out of line!");
    fx_text(m->x, m->y - 1, (Color){255, 120, 90, 255}, 1, "CHARGE!");
    play(SFX_SELECT);
    return 1;
}

/* the ladle bonk: walking into a foe. Free, but weak: about half your attack, no warmth spent. */
static void attack(Mon *m) {
    static const char *HIT[] = {"bonk", "whack", "bop", "smack", "thwack", "wallop"};
    const MonType *T = &MT[m->t];
    int d, crit;
    lunge(&PV.lunge, &PV.ldx, &PV.ldy, m->x - P.x, m->y - P.y);
    fx_bonk(m->x, m->y);
    if (m->x != P.x) PV.face = m->x > P.x ? 1.0f : -1.0f;
    if (!m->revealed) {
        m->revealed = 1;
        m->awake = 1;
        msg("You reach for the potion. The potion reaches back. MIMIC!");
        fx_text(m->x, m->y, (Color){255, 80, 80, 255}, 1, "MIMIC!");
    }
    d = player_atk() / 2 + 1 - m->def + rnd(3) - 1;
    if (d < 1) d = 1;
    if (m->t == M_DUCK && m->cstun > 0) d *= 2;   /* the wall-dazed duck is open to hits */
    crit = P.perk[PK_CRIT] && rnd(100) < 15;
    if (crit) d *= 3;
    d = duck_gate(m, d);
    m->hp -= d;
    m->awake = 1;
    if (!m->dozed) m->sleep = 0;
    m->flash = 1;
    fx_shake(crit ? 6.0f : 2.0f);
    fx_text(m->x, m->y, crit ? (Color){255, 220, 80, 255} : WHITE, crit, crit ? "%d CRIT!" : "%d", d);
    fx_burst(m->x, m->y, crit ? 16 : 7, (Color){255, 240, 200, 255}, 70, 1);
    play(SFX_HIT);
    msg("You %s the %s for %d.%s", HIT[rnd(6)], T->name, d, crit ? " CRIT!" : "");
    if (m->hp <= 0) { kill_mon(m); return; }
    spore_hit(m);
    if (m->t == M_DUCK) duck_phase(m);

    if (m->t == M_SLIME && m->hp >= 2) {
        int k, s = rnd(4);
        for (k = 0; k < 4; k++) {
            int dd = (s + k) % 4, nx = m->x + DX[dd], ny = m->y + DY[dd];
            if (free_at(nx, ny)) {
                Mon *c = spawn(M_SLIME, nx, ny);
                if (c) {
                    c->hp = m->hp / 2;
                    m->hp -= c->hp;
                    c->maxhp = c->hp;
                    c->awake = 1;
                    c->vx = (float)m->x;
                    c->vy = (float)m->y;
                    msg("The slime splits in two. Rude.");
                    fx_burst(nx, ny, 10, T->col, 50, 0);
                    play(SFX_SPLAT);
                }
                break;
            }
        }
    }
    if (rule == R_BOUNCY && m->t != M_DUCK) {
        int kx = m->x + (m->x - P.x), ky = m->y + (m->y - P.y);
        if (free_at(kx, ky)) {
            m->x = kx;
            m->y = ky;
            msg("Boing!");
            fx_text(kx, ky, (Color){130, 236, 120, 255}, 0, "boing");
        }
    }
    if (m->t == M_DUCK && rnd(4) == 0) msg("The duck looks personally offended.");
}

/* ========================================================== monsters === */

static void monsters_act(void) {
    int i;
    bfs();
    for (i = 0; i < nmon && state == S_PLAY; i++) {
        Mon *m = &mons[i];
        int seen_now, md;
        if (!m->alive) continue;
        seen_now = vis[m->y][m->x];
        md = mdist(m);
        if (m->sleep > 0) {
            if (--m->sleep == 0) m->dozed = 0;
            continue;
        }
        if (mon_stuck_turn(m)) continue;   /* stuck in the soup: loses its turn */

        switch (m->t) {
        case M_MIMIC:
            if (!m->revealed) {
                if (md == 1 && rnd(3) == 0) {
                    m->revealed = 1;
                    m->awake = 1;
                    msg("The potion next to you grows teeth. MIMIC!");
                    fx_text(m->x, m->y, (Color){255, 80, 80, 255}, 1, "MIMIC!");
                    fx_shake(3);
                }
                continue;
            }
            break;
        case M_SHROOM:
            if (md == 1) mon_attack(m);
            continue;
        case M_CHAIR:   /* only moves when you are not looking */
            if (md == 1) { mon_attack(m); continue; }
            if (!seen_now && dist[m->y][m->x] < 14) {
                step(m, 0);
                if (mdist(m) > 1 && rnd(2)) step(m, 0);
            }
            continue;
        case M_DRAFT:
            if (seen_now || md < 10) m->awake = 1;
            if (!m->awake) continue;
            if (m->fear > 0) { m->fear--; continue; }
            if (md == 1) { mon_attack(m); continue; }
            if (P.turns % 2 == 0) ghost_step(m);
            continue;
        }

        if (seen_now) m->awake = 1;
        if (!m->awake) continue;
        if (!seen_now && md > 1 && P.ing[G_CHAIR] && P.turns % (1 + (P.ing[G_CHAIR] > 3 ? 3 : P.ing[G_CHAIR])))
            continue;   /* chair splinter: unseen monsters only move now and then */

        if ((m->t == M_RAT || m->t == M_DUCKLING) && lick_act(m, md)) continue;
        if (m->t == M_OGRE) {
            if (m->fury > 0) m->fury--;
            if (P.turns % 2) continue;
            if (rnd(100) < 8) {
                m->sleep = 3;
                if (seen_now) msg("The ogre falls asleep mid-stomp. Relatable.");
                continue;
            }
        }
        if (m->t == M_GOBLIN && (m->gold || m->stole)) {
            if (!seen_now && md > 10) {
                m->alive = 0;
                msg("You hear smug, distant counting. That %s is gone.", m->gold ? "gold" : "ingredient");
                continue;
            }
            step(m, 1);
            continue;
        }
        if (m->fear > 0) { m->fear--; step(m, 1); continue; }
        if (m->t == M_DUCKLING) {   /* quick and nippy: two steps a turn */
            if (md == 1) mon_attack(m);
            else { step(m, 0); if (mdist(m) > 1) step(m, 0); }
            continue;
        }
        if (m->t == M_DUCK && duck_act(m, md)) continue;
        if (m->t == M_DUCK && seen_now && ++m->honk >= (m->phase >= 3 ? 4 : 6)) {
            m->honk = 0;
            if (P.stunned < 1) P.stunned = 1;
            msg("HONK. You freeze in primal terror.");
            fx_text(m->x, m->y - 1, (Color){255, 230, 90, 255}, 1, "HONK!");
            fx_ring(m->x, m->y, (Color){255, 230, 120, 255});
            fx_shake(9);
            play(SFX_HONK);
            continue;
        }
        if ((m->t == M_BAT || rule == R_DISCO) && rnd(2) == 0) { wander(m); continue; }
        if (md == 1) { mon_attack(m); continue; }
        step(m, 0);
        if (m->t == M_DUCK && m->fury && mdist(m) > 1) step(m, 0);   /* angry in hot soup: a second step */
    }
}

/* ============================================================ items === */

static void teleport_player(void) {
    int k;
    for (k = 0; k < 1000; k++) {
        int x = 1 + rnd(MW - 2), y = 1 + rnd(MH - 2);
        if (tile[y][x] == '.' && free_at(x, y)) {
            fx_burst(P.x, P.y, 20, (Color){200, 120, 255, 255}, 70, 1);
            P.x = x;
            P.y = y;
            PV.x = (float)x;
            PV.y = (float)y;
            fx_burst(P.x, P.y, 20, (Color){200, 120, 255, 255}, 70, 1);
            pickup();
            return;
        }
    }
}

static void drink(int s) {
    int x, y, dx, dy;
    play(SFX_DRINK);
    switch (s) {
    case 0:
        P.hp += 12;
        if (P.hp > P.maxhp) P.hp = P.maxhp;
        msg("You feel better. Medically speaking.");
        fx_text(P.x, P.y, (Color){120, 240, 120, 255}, 0, "+12 HP");
        fx_sparkle(P.x, P.y, 20, (Color){120, 255, 140, 255});
        break;
    case 1:
        P.atk++;
        msg("Your noodle arms feel slightly less noodly. ATK +1.");
        fx_text(P.x, P.y, (Color){255, 170, 90, 255}, 0, "ATK +1");
        break;
    case 2:
        msg("Wait, this goes in the soup. Spicy AND warm. +35 warmth.");
        fx_text(P.x, P.y, (Color){255, 120, 60, 255}, 1, "+35 soup");
        fx_burst(P.x, P.y, 16, (Color){255, 120, 40, 255}, 60, 1);
        add_soup(35);
        break;
    case 3:
        P.confused = 8;
        msg("The room spins. Wheee.");
        fx_text(P.x, P.y, (Color){220, 140, 240, 255}, 0, "wheee");
        break;
    case 4:
        teleport_player();
        msg("Blip! You are somewhere else now.");
        break;
    case 5:
        for (y = 0; y < MH; y++)
            for (x = 0; x < MW; x++) {
                if (tile[y][x] == '#') continue;
                for (dy = -1; dy <= 1; dy++)
                    for (dx = -1; dx <= 1; dx++)
                        if (inb(x + dx, y + dy)) seen[y + dy][x + dx] = 1;
            }
        msg("You suddenly know the floor plan. You don't want to know how.");
        fx_ring(P.x, P.y, (Color){160, 220, 255, 255});
        break;
    case 6:
        P.hp -= 5;
        PV.flash = 1;
        msg("That tasted like regret. It was regret. -5 HP.");
        fx_text(P.x, P.y, (Color){255, 90, 80, 255}, 0, "-5");
        if (P.hp <= 0) die("a potion of regret");
        break;
    case 7:
        P.maxhp += 5;
        P.hp += 5;
        msg("You feel sturdier, like a good chair. Max HP +5.");
        fx_text(P.x, P.y, (Color){120, 240, 120, 255}, 0, "MAX HP +5");
        break;
    }
}

static void read_scroll(int s) {
    int i, n = 0, duck = 0;
    Mon *best = NULL;
    play(SFX_SCROLL);
    switch (s) {
    case 0:
        msg("FWOOSH! The scroll bursts into flames. So does everything nearby.");
        play(SFX_FIRE);
        fx_shake(7);
        for (i = 0; i < nmon; i++) {
            Mon *m = &mons[i];
            if (!m->alive || !vis[m->y][m->x]) continue;
            n++;
            m->revealed = 1;
            m->awake = 1;
            m->flash = 1;
            m->hp -= duck_gate(m, 8 + P.floor);
            if (m->t == M_DUCK && m->hp > 0) duck_phase(m);
            fx_burst(m->x, m->y, 30, (Color){255, 140, 40, 255}, 90, 1);
            fx_text(m->x, m->y, (Color){255, 160, 60, 255}, 0, "%d", 8 + P.floor);
            if (m->hp <= 0) kill_mon(m);
        }
        fx_burst(P.x, P.y, 24, (Color){255, 120, 30, 255}, 120, 1);
        if (!n) msg("Nothing was nearby. You set fire to the air. Very dramatic.");
        break;
    case 1:
        for (i = 0; i < nmon; i++) {
            Mon *m = &mons[i];
            if (!m->alive || !vis[m->y][m->x]) continue;
            if (m->t == M_DUCK) { duck = 1; continue; }
            m->fear = 15;
            fx_text(m->x, m->y, (Color){200, 140, 255, 255}, 0, "!!");
            n++;
        }
        msg("You read it in a spooky voice. %s", n ? "It works!" : "No one is around to hear.");
        if (duck) msg("The duck is not afraid of you. The duck is not afraid of anything.");
        break;
    case 2:
        msg("*ding* Your soup is piping hot again.");
        fx_text(P.x, P.y, (Color){255, 200, 90, 255}, 1, "*ding*");
        add_soup(100);
        break;
    case 3:
        P.stunned += 3;
        msg("It is a 40-page form. You fill it out in triplicate. Time passes.");
        fx_text(P.x, P.y, (Color){200, 200, 200, 255}, 0, "paperwork...");
        break;
    case 4:
        for (i = 0; i < 40 && n < 5; i++) {
            int x = P.x + rnd(5) - 2, y = P.y + rnd(5) - 2;
            if (walkable(x, y) && !item_at(x, y) && !(x == P.x && y == P.y)) {
                add_item(I_GOLD, 0, x, y, 5 + rnd(10));
                fx_sparkle(x, y, 6, (Color){255, 220, 90, 255});
                n++;
            }
        }
        msg("Coins rain from the ceiling. Ow. Profitable ow.");
        play(SFX_COIN);
        break;
    case 5:
        for (i = 0; i < nmon; i++) {
            Mon *m = &mons[i];
            if (!m->alive || !m->revealed || !vis[m->y][m->x] || !walkable(m->x, m->y)) continue;
            if (!best || mdist(m) < mdist(best)) best = m;
        }
        if (best) {
            int tx = best->x, ty = best->y;
            best->x = P.x;
            best->y = P.y;
            P.x = tx;
            P.y = ty;
            msg("You and the %s swap places. Awkward.", MT[best->t].name);
            pickup();
        } else {
            msg("Nothing to swap with. You swap your socks instead.");
        }
        break;
    case 6:
        for (i = 0; i < NPOT; i++) pot_known[i] = 1;
        for (i = 0; i < NSCR; i++) scr_known[i] = 1;
        msg("Knowledge! It is mostly reading labels.");
        fx_sparkle(P.x, P.y, 16, (Color){180, 220, 255, 255});
        break;
    case 7:
        for (i = 0; i < nmon; i++) {
            Mon *m = &mons[i];
            if (!m->alive || !vis[m->y][m->x]) continue;
            m->sleep = m->t == M_DUCK ? 3 : 12;
            m->awake = 0;
        }
        msg("Everything nearby takes a nap. Rude, but useful.");
        break;
    }
}

static int use_item(int i) {
    Pocket it;
    int was;
    if (i >= ninv) { msg("Pocket %d is empty. You check twice.", i + 1); return 0; }
    it = inv[i];
    memmove(&inv[i], &inv[i + 1], sizeof(Pocket) * (size_t)(ninv - i - 1));
    ninv--;
    if (it.k == I_POTION) {
        was = pot_known[it.sub];
        pot_known[it.sub] = 1;
        drink(it.sub);
        if (!was) msg("(That was a potion of %s.)", POT_EFF[it.sub]);
    } else if (it.k == I_ROCK) {
        msg("You drop a warm rock into your soup. It is... warmer. +20 warmth.");
        fx_text(P.x, P.y, (Color){255, 150, 70, 255}, 0, "+20 soup");
        play(SFX_PICK);
        add_soup(20);
    } else if (it.k == I_INGREDIENT) {
        add_ingredient(it.sub);
    } else {
        was = scr_known[it.sub];
        scr_known[it.sub] = 1;
        read_scroll(it.sub);
        if (!was) msg("(That was a scroll of %s.)", SCR_EFF[it.sub]);
    }
    return 1;
}

static void pickup(void) {
    int i;
    for (i = 0; i < nitem; i++) {
        Item *it = &items[i];
        if (!it->alive || it->x != P.x || it->y != P.y) continue;
        switch (it->k) {
        case I_GOLD: {
            int a = it->amt * (P.perk[PK_PENNIES] ? 2 : 1);
            P.gold += a;
            msg("You pick up %d gold. Shiny.", a);
            fx_text(P.x, P.y, (Color){252, 212, 78, 255}, 0, "+%dg", a);
            fx_sparkle(P.x, P.y, 8, (Color){255, 220, 90, 255});
            play(SFX_COIN);
            it->alive = 0;
            break;
        }
        case I_WEAPON:
            P.ladle++;
            msg("You find a %s! Splashes hit harder: +3.", WEAPONS[it->sub]);
            fx_text(P.x, P.y, (Color){255, 170, 90, 255}, 0, "SPLASH +3");
            play(SFX_PICK);
            it->alive = 0;
            break;
        case I_ARMOR:
            P.lids++;
            P.def++;
            msg("You put on a %s. Soup cools slower. DEF +1.", ARMORS[it->sub]);
            fx_text(P.x, P.y, (Color){150, 200, 255, 255}, 0, "COOLS SLOWER");
            play(SFX_PICK);
            it->alive = 0;
            break;
        default: {
            Pocket p;
            char n[48];
            p.k = it->k;
            p.sub = it->sub;
            pocket_name(p, n, sizeof n, 1);
            if (ninv < INVMAX) {
                inv[ninv++] = p;
                it->alive = 0;
                msg("You pocket: %s [key %d]", n, ninv);
                play(SFX_PICK);
                tip_note(TIP_ITEM);
                if (p.k == I_INGREDIENT) tip_note(TIP_INGREDIENT);
            } else {
                msg("Your pockets are full. The %s stays here.", n);
            }
        }
        }
    }
}

/* bumping the machine opens its menu; no turn passes until you buy something */
static void open_vend(void) {
    int i;
    vend_open = 1;
    msg("The vending machine hums. Three offers. Pick one, or walk away.");
    for (i = 0; i < 3; i++)
        if (offers[i].moody) msg("The machine's screen flickers. One offer reads ???.");
    lunge(&PV.lunge, &PV.ldx, &PV.ldy, vend_x - P.x, vend_y - P.y);
}

/* returns 1 if you bought it, which spends the turn */
static int buy_offer(int i) {
    Offer *o = &offers[i];
    Pocket p;
    int price = offer_price(o);
    char n[48];
    if (o->sold) { msg("Sold out. The machine blinks apologetically."); return 0; }
    if (ninv >= INVMAX) { msg("Your pockets are full. The vending machine sighs."); return 0; }
    if (P.gold < price) {
        msg("The machine wants %d gold. You have %d. It hums judgmentally.", price, P.gold);
        fx_text(vend_x, vend_y, (Color){150, 170, 255, 255}, 0, "%dg pls", price);
        return 0;
    }
    P.gold -= price;
    o->sold = 1;
    p.k = o->k;
    p.sub = o->sub;
    inv[ninv++] = p;
    pocket_name(p, n, sizeof n, 1);
    play(SFX_VEND);
    if (o->moody) msg("The machine is in a mood. *clunk* You get: %s [key %d]", n, ninv);
    else msg("*clunk* You get: %s [key %d]", n, ninv);
    fx_text(vend_x, vend_y, (Color){150, 200, 255, 255}, 0, "*clunk*");
    return 1;
}

/* ============================================== halfway kitchen === */

/* Between floor KITCHEN_FLOOR and the next, a safe kitchen sells pot upgrades for the rest of this run.
   Its offers come from their own dice, seeded from the run, so they never shift a floor or a fight.
   Buying costs gold, not turns: nothing moves while the kitchen is open. */
#define KIT_CHEAP 70       /* an offer at or under this price is cheap, and one offer is always cheap */
#define KIT_SALT 0x4B17C4E0ULL
static unsigned long long rs_kit = 1;
static int kitchen_open;   /* set on arrival at the kitchen, cleared when you leave for the next floor */
static const char *KIT_NAME[K_N] = {
    "Bigger Pot", "Copper Pot", "Wide Ladle", "Long Ladle", "Cast-Iron Lid", "Spice Rack", "Second Helping", "Warm Hearth"};
static const int KIT_PRICE[K_N] = {125, 115, 100, 95, 140, 90, 55, 60};
static const char *KIT_DESC[K_N] = {
    "A pot the size of a small bathtub. Every floor starts with +10 warmth.",
    "Heavy copper holds the heat. Soup cools 2 turns slower.",
    "A ladle the width of a dinner plate. Every splash hits 3 harder.",
    "A ladle with ambitions. Flings reach 3 tiles farther.",
    "Heavy, dignified, good at blocking. Every hit you take is 1 lighter, and always at least 1.",
    "A rack of spices, slightly open. One random ingredient goes straight into your soup.",
    "Seconds, please. Slurps heal half again as much.",
    "The kitchen fire roars. Your soup is reheated to 100 right now."};
typedef struct { int k, sub; } KitOffer;   /* k: the upgrade; sub: the ingredient a Spice Rack adds */
static KitOffer kit[3];

/* Three upgrades from the pool of eight, one of them cheap. The same run always gets the same three. */
static void roll_kitchen(void) {
    int i, j, t, pool[K_N];
    for (i = 0; i < K_N; i++) pool[i] = i;
    rs_kit = mix64(g_seed ^ KIT_SALT);
    rsp = &rs_kit;
    for (i = K_N - 1; i > 0; i--) {
        j = rnd(i + 1);
        t = pool[i]; pool[i] = pool[j]; pool[j] = t;
    }
    for (i = 0; i < K_N && KIT_PRICE[pool[i]] > KIT_CHEAP; i++);
    if (i < K_N) {   /* the first cheap upgrade moves into a random one of the three slots */
        j = rnd(3);
        t = pool[i]; pool[i] = pool[j]; pool[j] = t;
    }
    for (i = 0; i < 3; i++) {
        kit[i].k = pool[i];
        kit[i].sub = rnd(G_N);
    }
    rsp = &rs_game;
}

/* an offer's description; a Spice Rack names the ingredient it will add */
static void kit_desc(int i, char *out, size_t n) {
    if (kit[i].k == K_SPICE) snprintf(out, n, "%s This time: %s.", KIT_DESC[K_SPICE], ING_NAME[kit[i].sub]);
    else snprintf(out, n, "%s", KIT_DESC[kit[i].k]);
}

/* returns 1 if you bought it. Each upgrade is bought once; Warm Hearth and Spice Rack act at once. */
static int buy_kit(int i) {
    const KitOffer *o = &kit[i];
    int price = KIT_PRICE[o->k];
    if (P.kit[o->k]) { msg("You already bought the %s. The chef nods at it.", KIT_NAME[o->k]); return 0; }
    if (P.gold < price) {
        msg("The %s costs %d gold. You have %d. The chef looks at your pockets.", KIT_NAME[o->k], price, P.gold);
        return 0;
    }
    P.gold -= price;
    P.kit[o->k] = 1;
    play(SFX_VEND);
    msg("You buy the %s for %d gold.", KIT_NAME[o->k], price);
    if (o->k == K_HEARTH) set_soup(100);
    if (o->k == K_SPICE) add_ingredient(o->sub);
    return 1;
}

/* the first moment of a floor: its rule is announced, and the Bigger Pot adds warmth */
static void start_floor(void) {
    msg("Floor %d. %s: %s", P.floor, RULE_NAME[rule], RULE_DESC[rule]);
    if (P.kit[K_BIGPOT]) add_soup(10);
}

/* on the way down from floor KITCHEN_FLOOR: a rest stop (soup +10, HP +5), then the offers */
static void kitchen_arrive(void) {
    add_soup(10);
    P.hp += 5;
    if (P.hp > P.maxhp) P.hp = P.maxhp;
    roll_kitchen();
    kitchen_open = 1;
    msg("The halfway kitchen is warm and smells of soup. Your soup reheats (+10) and you feel patched up (+5 HP).");
}

/* leaving the kitchen: the next floor was generated on arrival, so this only starts it */
static void kitchen_leave(void) {
    kitchen_open = 0;
    start_floor();
}

/* ========================================================= soup moves === */

/* ============================================================ starting soups === */

/* The soup a run starts with. The page offers the ones unlocked in the cookbook and passes the choice
   in with web_set_soup. Native builds stay on Tomato. Each soup changes one or two clear things. */
enum { SOUP_TOMATO, SOUP_MISO, SOUP_CHOWDER, SOUP_N };
static int soup_kind = SOUP_TOMATO;
static const char *SOUP_NAME[SOUP_N] = {"Tomato", "Miso", "Chowder"};
static const char *SOUP_DESC[SOUP_N] = {
    "The classic. Hits as printed.",
    "Cools slower but hits a little softer.",
    "Thick: lukewarm hits hard and sticks longer. Scalding cools fast."};

/* the soup's power before its band and the foe's armour: warmer soup hits harder. Miso is 1 weaker. */
static int soup_power(int warmth) { return 4 + warmth / 10 + P.atk / 3 - (soup_kind == SOUP_MISO); }

/* the band's percentage of that power: chowder hits lukewarm soup in full */
static int soup_band_pct(int band) { return soup_kind == SOUP_CHOWDER && band == B_LUKE ? 100 : BAND_PCT[band]; }

/* percent of the normal cooling time per degree: the difficulty sets it, miso cools an eighth slower,
   and chowder cools a quarter faster while it is scalding */
static int soup_cool_pct(void) {
    static const int cool_pct[3] = {150, 100, 70};
    int pct = cool_pct[cool_level];
    if (soup_kind == SOUP_MISO) pct = pct * 9 / 8;
    if (soup_kind == SOUP_CHOWDER && P.soup >= 80) pct = pct * 3 / 4;
    return pct;
}

#define SLURP_COST 15
#define SPLASH_COST 12
#define FLING_COST 8
#define FLING_RANGE 6
/* a Long Ladle reaches three tiles farther */
static int fling_range(void) { return FLING_RANGE + (P.kit[K_LONG] ? 3 : 0); }

/* the soup pays out on every hit that is not cold: rat tails heal, mimic teeth pay gold */
static void soup_pay(void) {
    int tails = P.ing[G_RAT] > 3 ? 3 : P.ing[G_RAT], teeth = P.ing[G_MIMIC] > 3 ? 3 : P.ing[G_MIMIC];
    if (tails) {
        P.hp += tails;
        if (P.hp > P.maxhp) P.hp = P.maxhp;
        fx_text(P.x, P.y, (Color){120, 240, 120, 255}, 0, "+%d HP", tails);
    }
    if (teeth) {
        P.gold += 2 * teeth;
        fx_text(P.x, P.y - 1, (Color){252, 212, 78, 255}, 0, "+%dg", 2 * teeth);
    }
}

/* the recipe's statuses and pushes, on a foe that is still standing. Cold soup carries none of it. */
static void soup_status(Mon *m, int band) {
    int burn0 = m->burn, stuck0 = m->stuck, peppers_n = peppers();
    int spicy = combo_on(C_SPICY), frozen = combo_on(C_FREEZE);
    if (band == B_SCALD && m->t != M_SLIME) set_burn(m, 3, 2);   /* scalding soup sets things alight, but slimes drink it */
    if (band == B_LUKE) set_stuck(m, soup_kind == SOUP_CHOWDER ? 2 : 1);   /* lukewarm soup sticks, thick chowder longer */
    if (peppers_n) set_burn(m, 2 + peppers_n + (spicy ? 2 : 0), peppers_n);
    if (P.ing[G_SLIME]) set_stuck(m, 1 + frozen);
    if (P.ing[G_DRAFT]) { set_stuck(m, 2 + frozen); fx_mark_frozen(m); }
    if (m->burn && !burn0) msg("The %s catches fire.", MT[m->t].name);
    if (m->stuck > stuck0) msg("The %s is %s.", MT[m->t].name, P.ing[G_DRAFT] ? "frozen solid" : "stuck in the soup");
    if (P.ing[G_OGRE]) {   /* ogre toenail: the soup shoves */
        int kx = m->x + (m->x - P.x), ky = m->y + (m->y - P.y);
        if (free_at(kx, ky)) {
            mon_moveto(m, kx, ky);
            msg("The soup shoves the %s back a tile.", MT[m->t].name);
            fx_text(kx, ky, (Color){130, 236, 120, 255}, 0, "shove");
        }
    }
    spore_hit(m);
}

/* the ogre loves soup. Hot soup makes it furious: its hits land harder for a few turns. Lukewarm soup
   makes it sleepy: it dozes for a few turns, and bonks do not wake it (hot soup does). */
static void ogre_soup(Mon *m, int band) {
    if (band == B_HOT || band == B_SCALD) {
        if (!m->fury) msg("The ogre tastes the hot soup. It is FURIOUS.");
        m->fury = 3;
        m->dozed = 0;
        m->sleep = 0;
    } else if (band == B_LUKE) {
        if (!m->dozed) msg("The ogre tastes the lukewarm soup. Mmm. Nap time.");
        m->dozed = 1;
        m->sleep = 3;
        fx_text(m->x, m->y - 1, (Color){160, 200, 255, 255}, 0, "zzz");
    }
}

/* One soup hit on one foe. The band comes from the warmth passed in (taken before the soup is paid for);
   extra is splash ladle damage. A flying hit leaves its number to the projectile that lands it.
   Slimes drink soup instead of taking damage. Returns the damage dealt. */
/* splash hits everything around you, so each foe takes 60% of a soup hit; its burn, stick and puddles are full */
#define SPLASH_PCT 60
static int splashing;
static int soup_strike(Mon *m, int warmth, int extra, int flying) {
    const MonType *T = &MT[m->t];
    int band = soup_band(warmth), d, dealt = 0;
    if (!m->revealed) {
        m->revealed = 1;
        m->awake = 1;
        msg("The soup finds the potion. It was a MIMIC!");
    }
    d = (soup_power(warmth) + extra) * soup_band_pct(band) / 100;
    if (splashing) d = d * SPLASH_PCT / 100;
    d -= m->def;
    if (m->t == M_DRAFT) d /= 2;                        /* a draft is mostly air: soup hits it weaker */
    if (d < 1) d = 1;
    if (m->t == M_SHROOM && band == B_SCALD) d *= 2;    /* scalding cooks a sporeshroom */
    if (m->t == M_DUCK && m->cstun > 0) d *= 2;         /* the wall-dazed duck is open to soup too */
    m->awake = 1;
    if (!m->dozed) m->sleep = 0;
    m->flash = 1;
    if (m->t == M_SLIME) {
        int before = m->hp, grow = band == B_COLD ? 0 : d / 4 > 0 ? d / 4 : 1;
        m->hp += grow;
        if (m->hp > m->maxhp) m->maxhp = m->hp;
        if (m->maxhp > SLIME_MAX) {
            m->maxhp = SLIME_MAX;
            if (m->hp > m->maxhp) m->hp = m->maxhp;
        }
        grow = m->hp - before;
        if (grow) fx_text(m->x, m->y, BAND_COL[band], 0, "+%d", grow);
        fx_burst(m->x, m->y, 8, BAND_COL[band], 40, 1);
        play(SFX_DRINK);
        if (band == B_COLD) msg("Cold soup dribbles off the %s. It is not impressed.", T->name);
        else if (grow) msg("The %s soup soaks into the slime. +%d HP, and it swells.", BAND_ADJ[band], grow);
        else msg("The slime is too full for more soup. Ladle it, don't splash it.");
    } else {
        int crit = P.perk[PK_CRIT] && rnd(100) < 15;
        if (crit) d *= 3;
        d = duck_gate(m, d);
        m->hp -= d;
        dealt = d;
        fx_shake(crit ? 6.0f : 2.0f);
        if (!flying) fx_text(m->x, m->y, crit ? (Color){255, 220, 80, 255} : BAND_COL[band], crit, crit ? "%d CRIT!" : "%d", d);
        fx_burst(m->x, m->y, crit ? 16 : 8, BAND_COL[band], 70, 1);
        play(SFX_SPLAT);
        if (band == B_COLD) msg("Cold soup dribbles on the %s for %d. Sad.", T->name, d);
        else msg("The %s soup hits the %s for %d.%s", BAND_ADJ[band], T->name, d, crit ? " CRIT!" : "");
    }
    if (band != B_COLD) soup_pay();
    if (m->hp <= 0) { kill_mon(m); return dealt; }
    if (m->t == M_DUCK) duck_phase(m);
    if (m->t == M_OGRE) ogre_soup(m, band);
    if (band != B_COLD) soup_status(m, band);
    return dealt;
}

/* SPLASH: everything on the eight tiles around you. Costs warmth; the band is read before the cost.
   Refused when nothing is next to you. Spills soup puddles on the eight tiles. */
static int splash(void) {
    static const int AX[8] = {-1, 0, 1, -1, 1, -1, 0, 1}, AY[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
    Mon *tg[8];
    int k, n = 0, warm = P.soup;
    if (P.soup <= SPLASH_COST) { msg("Not enough warmth to splash. The soup would give up."); return 0; }
    if (soup_band(P.soup) == B_COLD) { msg("Your soup is too cold to fight with. Warm it up first."); return 0; }
    for (k = 0; k < 8; k++) {
        Mon *m = mon_at(P.x + AX[k], P.y + AY[k]);
        if (m) tg[n++] = m;
    }
    if (!n) { msg("Nothing next to you to splash. You keep your soup."); return 0; }
    add_soup(-SPLASH_COST);
    for (k = 0; k < 8; k++) spill(P.x + AX[k], P.y + AY[k], warm);
    tip_note(TIP_PUDDLE);
    fx_soup_wave(P.x, P.y, soup_band(warm));
    play(SFX_SPLAT);
    msg("SPLASH! The %s soup goes everywhere. %d foe%s in reach.", BAND_ADJ[soup_band(warm)], n, n == 1 ? " is" : "s are");
    splashing = 1;
    for (k = 0; k < n; k++)
        if (tg[k]->alive) soup_strike(tg[k], warm, 3 * P.ladle + (P.kit[K_WIDE] ? 3 : 0), 0);
    splashing = 0;
    return 1;
}

/* SLURP (was sip): heals for warmth. Refused when it would chill the soup or you are full. */
static int slurp(void) {
    int heal = 6 + P.lvl;
    if (P.kit[K_SECOND]) heal += heal / 2;
    if (P.soup <= SLURP_COST) { msg("Not enough warmth to slurp. The soup would go cold on you."); return 0; }
    if (P.hp >= P.maxhp) { msg("You are already full of soup. Save it for later."); return 0; }
    add_soup(-SLURP_COST);
    P.hp += heal;
    if (P.hp > P.maxhp) P.hp = P.maxhp;
    play(SFX_DRINK);
    msg("You slurp the soup. Warm, salty, mildly crunchy. +%d HP.", heal);
    fx_text(P.x, P.y, (Color){120, 240, 120, 255}, 0, "+%d HP", heal);
    fx_text(P.x, P.y - 1, (Color){255, 244, 214, 255}, 0, "slurp");
    fx_steam(tcx(P.x), tcy(P.y) - 8, fx_on ? 9 : 3);
    if (fx_on) fx_sparkle(P.x, P.y, 12, (Color){120, 255, 140, 255});
    slurp_t = 0.9f;
    return 1;
}

/* a bat flaps out of the way of a fling about a third of the time */
static int fling_dodged(Mon *m) {
    if (m->t != M_BAT || rnd(100) >= BAT_DODGE) return 0;
    msg("The bat flaps out of the soup's way. Bats are sneaky.");
    fx_text(m->x, m->y, (Color){200, 240, 255, 255}, 0, "dodge!");
    return 1;
}

/* FLING: a lob of soup at the first foe in a line of six tiles (nine with a Long Ladle). Refused when nothing is in line.
   A bat wing lets it pierce on to the next foe. Spills a puddle where each foe is hit. */
static int fling(int d) {
    static const char *WAY[4] = {"up", "down", "left", "right"};
    Mon *tg[2];
    int k, n = 0, steps = 0, wall = 0, maxhit = P.ing[G_BAT] ? 2 : 1, warm, dmg;
    if (P.soup <= FLING_COST) { msg("Not enough warmth to fling. Cold gazpacho does not fling well."); return 0; }
    if (soup_band(P.soup) == B_COLD) { msg("Your soup is too cold to fight with. Warm it up first."); return 0; }
    for (k = 1; k <= fling_range(); k++) {   /* no target in line: keep the soup */
        int x = P.x + DX[d] * k, y = P.y + DY[d] * k;
        Mon *m;
        if (!inb(x, y) || !walkable(x, y)) { wall = 1; break; }
        steps = k;
        if ((m = mon_at(x, y))) {
            tg[n++] = m;
            if (n == maxhit) break;
        }
    }
    if (!n) { msg("Nothing to fling at %s. You keep your soup.", WAY[d]); return 0; }
    warm = P.soup;
    add_soup(-FLING_COST);
    lunge(&PV.lunge, &PV.ldx, &PV.ldy, DX[d], DY[d]);
    msg("Your soup flies %s.", WAY[d]);
    dmg = fling_dodged(tg[0]) ? 0 : soup_strike(tg[0], warm, 0, 1);
    fx_fling(d, steps, wall, dmg > 0, dmg, tg[0]->x, tg[0]->y, soup_band(warm));
    if (n > 1) fx_fling_through(tg[0]->x, tg[0]->y, tg[1]->x, tg[1]->y, soup_band(warm));
    spill(tg[0]->x, tg[0]->y, warm);
    tip_note(TIP_PUDDLE);
    if (n > 1) {
        spill(tg[1]->x, tg[1]->y, warm);
        if (tg[1]->alive && !fling_dodged(tg[1])) soup_strike(tg[1], warm, 0, 0);
    }
    return 1;
}

/* ===================================================== level + perks === */

static int perk_opts[3], nperk_opts;

static void apply_perk(int p) {
    int i;
    P.perk[p]++;
    switch (p) {
    case PK_ELBOWS: P.atk += 2; break;
    case PK_SOCKS: P.def++; break;
    case PK_BREAKFAST: P.maxhp += 8; P.hp += 8; break;
    case PK_AURA:
        for (i = 0; i < NPOT; i++) pot_known[i] = 1;
        for (i = 0; i < NSCR; i++) scr_known[i] = 1;
        break;
    }
    msg("You gain %s.", PERK_NAME[p]);
}

static void roll_perks(void) {
    int tries = 0, i;
    nperk_opts = 0;
    while (nperk_opts < 3 && tries++ < 500) {
        int p = rnd(PK_N), dup = 0;
        if (!PK_STACK[p] && P.perk[p]) continue;
        for (i = 0; i < nperk_opts; i++)
            if (perk_opts[i] == p) dup = 1;
        if (!dup) perk_opts[nperk_opts++] = p;
    }
}

static void check_level(void) {
    while (state == S_PLAY && P.xp >= need(P.lvl)) {
        P.lvl++;
        P.maxhp += 3;
        P.hp += 5;
        if (P.hp > P.maxhp) P.hp = P.maxhp;
        pending_levels++;
        msg("LEVEL UP! You are now level %d.", P.lvl);
        fx_text(P.x, P.y, (Color){255, 230, 100, 255}, 1, "LEVEL UP!");
        fx_sparkle(P.x, P.y, 30, (Color){255, 220, 90, 255});
        play(SFX_LEVEL);
    }
}

/* ========================================================= the turn === */

static int try_move(int dx, int dy) {
    int nx, ny;
    Mon *m;
    if (rule == R_BACKWARDS) { dx = -dx; dy = -dy; }
    if (P.confused > 0 && rnd(2) == 0) {
        int d = rnd(4);
        dx = DX[d];
        dy = DY[d];
        if (rnd(3) == 0) msg("You stumble around. Wheee.");
    }
    if (dx) PV.face = dx > 0 ? 1.0f : -1.0f;
    nx = P.x + dx;
    ny = P.y + dy;
    if (!inb(nx, ny)) return 0;
    if ((m = mon_at(nx, ny))) { attack(m); return 1; }
    if (tile[ny][nx] == 'V') { open_vend(); return 0; }
    if (!walkable(nx, ny)) {
        if (P.confused > 0) {
            msg("You walk into a wall. Confidently.");
            lunge(&PV.lunge, &PV.ldx, &PV.ldy, dx, dy);
            return 1;
        }
        return 0;
    }
    P.x = nx;
    P.y = ny;
    play(SFX_STEP);
    if (rule == R_SLIPPERY) {   /* you slide up to 4 extra tiles; the butter decides */
        int slid = 0;
        while (slid < 4 && rnd(100) < 65 && !item_at(P.x, P.y) && tile[P.y][P.x] != '>'
               && free_at(P.x + dx, P.y + dy)) {
            P.x += dx;
            P.y += dy;
            slid++;
        }
        if (slid >= 2 && rnd(2) == 0) msg("Wheeeeee!");
    }
    pickup();
    stair_hints();
    if (tile[P.y][P.x] == '>') {
        int s = stair_at(P.x, P.y);
        if (s >= 0) msg("Stairs down to the %s floor. Press E or > to descend.", RULE_NAME[stair_rule[s]]);
        else msg("Stairs down. Press E or > to descend.");
    }
    return 1;
}

static void descend(void) {
    int i = stair_at(P.x, P.y);
    next_rule = i >= 0 ? stair_rule[i] : -1;
    cb_note(CB_RULE, rule);   /* the rule of the floor we are leaving is cleared */
    P.floor++;
    gen_floor();
    play(SFX_STAIRS);
    floor_fade = 1;
    banner_t = 0;
    if (P.floor == KITCHEN_FLOOR + 1) kitchen_arrive();
    else start_floor();
}

/* returns 1 if the action used a turn */
static int act(int k) {
    int dx = 0, dy = 0;
    switch (k) {
    case 'w': dy = -1; break;
    case 's': dy = 1; break;
    case 'a': dx = -1; break;
    case 'd': dx = 1; break;
    case '.': return 1;
    case 'r': return slurp();
    case 'f': return splash();
    case 'W': return fling(0);
    case 'S': return fling(1);
    case 'A': return fling(2);
    case 'D': return fling(3);
    case '>':
        if (tile[P.y][P.x] == '>') descend();
        else msg("No stairs here. Just regular, load-bearing floor.");
        return 0;
    default:
        if (k >= '1' && k <= '9') return use_item(k - '1');
        return 0;
    }
    return try_move(dx, dy);
}

static void end_turn(void) {
    if (state != S_PLAY) return;
    P.turns++;
    if (++P.soup_tick >= soup_interval() * soup_cool_pct() / 100) {
        P.soup_tick = 0;
        add_soup(-1);
    }
    if (P.turns % regen_every() == 0 && P.hp < P.maxhp) P.hp++;
    if (P.confused > 0 && --P.confused == 0) msg("Your head clears.");
    if (state != S_PLAY) return;
    puddles_cool();
    mon_status_tick();
    if (state != S_PLAY) return;
    compute_fov();
    if (rule != R_COFFEE || P.turns % 2 == 0) monsters_act();
}

/* the tips whose conditions hold now. Runs after each turn (the field of view is current then) */
static void tips_check(void) {
    int i, d, k;
    if (state != S_PLAY) return;
    if (P.soup < 50) tip_note(TIP_SOUP_HALF);
    if (P.soup < 25) tip_note(TIP_SOUP_LOW);
    if (P.hp * 2 < P.maxhp) tip_note(TIP_HP_LOW);
    for (i = 0; i < nmon; i++) {   /* a disguised mimic looks like a potion, so it does not count */
        const Mon *m = &mons[i];
        if (m->alive && vis[m->y][m->x] && (m->t != M_MIMIC || m->revealed)) { tip_note(TIP_MONSTER); break; }
    }
    for (i = 0; i < nstairs; i++)
        if (vis[stair_y[i]][stair_x[i]]) { tip_note(TIP_STAIRS); break; }
    if (vend_x >= 0 && vis[vend_y][vend_x]) tip_note(TIP_VENDING);
    /* a fling would reach a foe two to five tiles away in a straight line, with nothing in between */
    if (soup_band(P.soup) != B_COLD && P.soup > FLING_COST) {
        for (d = 0; d < 4; d++)
            for (k = 1; k <= 5; k++) {
                int x = P.x + DX[d] * k, y = P.y + DY[d] * k;
                Mon *m;
                if (!walkable(x, y)) break;
                if (!(m = mon_at(x, y))) continue;
                if (k >= 2 && vis[y][x] && (m->t != M_MIMIC || m->revealed)) tip_note(TIP_FLING);
                break;
            }
    }
}

static void do_turn(int k) {
    int kills = P.kills;
    if (state != S_PLAY) return;
    if (act(k) && state == S_PLAY) {
        if (P.kills > kills && P.ing[G_BAT]) {   /* bat wing: a kill costs no time */
            msg("The bat wing flaps. That one was free.");
        } else {
            end_turn();
            while (P.stunned > 0 && state == S_PLAY) {
                P.stunned--;
                end_turn();
            }
        }
    }
    compute_fov();
    tips_check();
}

static void new_game(void) {
    int i, j, perm[NLOOK];
    memset(&P, 0, sizeof P);
    P.hp = P.maxhp = 20;
    P.atk = 3;
    P.lvl = 1;
    P.soup = 100;
    P.floor = START_FLOOR < 1 ? 1 : START_FLOOR > FLOORS ? FLOORS : START_FLOOR;
    ninv = 0;
    nlog = 0;
    prev_rule = -1;
    next_rule = -1;
    vend_open = 0;
    kitchen_open = 0;
    phase_t = 99;
    pending_levels = 0;
    aim = 0;
    state = S_PLAY;
    cause[0] = 0;
    npart = nftxt = nfade = nring = nwave = nbonk = 0;
    tip_said = 0;
    memset(mon_frozen, 0, sizeof mon_frozen);
    shake = hurt_flash = 0;
    PV.face = 1;
    memset(pot_known, 0, sizeof pot_known);
    memset(scr_known, 0, sizeof scr_known);
    rs_game = mix64(g_seed);

    rs_gen = mix64(g_seed ^ 0x50D50D50DULL);
    rsp = &rs_gen;
    for (i = 0; i < NLOOK; i++) perm[i] = i;
    for (i = NLOOK - 1; i > 0; i--) {
        int t;
        j = rnd(i + 1);
        t = perm[i]; perm[i] = perm[j]; perm[j] = t;
    }
    for (i = 0; i < NPOT; i++) {
        pot_look[i] = perm[i];
        snprintf(pot_name[i], sizeof pot_name[i], "%s", POT_LOOK[perm[i]]);
    }
    for (i = 0; i < NSCR; i++) {
        int dup;
        do {
            snprintf(scr_name[i], sizeof scr_name[i], "%s %s", SYL[rnd(NSYL)], SYL[rnd(NSYL)]);
            dup = 0;
            for (j = 0; j < i; j++)
                if (!strcmp(scr_name[i], scr_name[j])) dup = 1;
        } while (dup);
    }
    rsp = &rs_game;

    gen_floor();
    compute_fov();
    msg("Your soup is getting cold. The spoon is on floor %d.", FLOORS);
    msg("Move with WASD or arrows. Press ? for help.");
    banner_t = 0;
    floor_fade = 1;
    tip_note(TIP_START);
    tips_check();
}

/* ====================================================== presentation === */

enum { UI_TITLE, UI_PLAY, UI_PERK, UI_HELP, UI_CHAR, UI_CONFIRM, UI_END, UI_VEND, UI_KITCHEN };
static int ui = UI_TITLE, quit_now;
static float end_delay, perk_anim;
static int perk_hover = -1;
static Rectangle perk_rect[3];   /* where the perk cards were drawn, so a tap lands on the card under it */

/* clickable things on the canvas: drawing registers them, a click presses their key */
#define NHOT 24
static struct { Rectangle r; int key; } hot_rects[NHOT];
static int nhot, hot_hovered, click_key, cursor_hand;
static int hot_add(Rectangle r, int key) {
    int on = CheckCollisionPointRec(GetMousePosition(), r);
    if (nhot < NHOT) { hot_rects[nhot].r = r; hot_rects[nhot].key = key; nhot++; }
    if (on) hot_hovered = 1;
    return on;
}

static RenderTexture2D rt;
static int vw, vh, pscale;
static float uis = 1;   /* UI scale */
static Vector2 cam;
static float cam_left, cam_top;
static Shader post;
static int loc_texel, loc_hurt, loc_cold, loc_scale;

/* Small browser layouts use HTML for readable HUD text and scrollable menus. */
static int compact_ui(void) {
#if defined(__EMSCRIPTEN__)
    return EM_ASM_INT({ return window.innerWidth < 1000 || window.innerHeight < 700; });
#else
    return 0;
#endif
}

static const char *POST_FS =
#if defined(__EMSCRIPTEN__)
    "#version 300 es\n"
    "precision mediump float;\n"
#else
    "#version 330\n"
#endif
    "in vec2 fragTexCoord;\n"
    "in vec4 fragColor;\n"
    "uniform sampler2D texture0;\n"
    "uniform vec2 texel;\n"
    "uniform float hurt;\n"
    "uniform float cold;\n"
    "uniform float scale;\n"
    "out vec4 finalColor;\n"
    "void main() {\n"
    "    vec2 uv = fragTexCoord;\n"
    "    vec2 c = uv - 0.5;\n"
    "    float r2 = dot(c, c);\n"
    "    vec2 off = c * r2 * 0.010;\n"
    "    vec3 col = vec3(texture(texture0, uv + off).r, texture(texture0, uv).g, texture(texture0, uv - off).b);\n"
    "    vec3 b = vec3(0.0);\n"
    "    for (int i = -2; i <= 2; i++)\n"
    "        for (int j = -2; j <= 2; j++) {\n"
    "            vec3 s = texture(texture0, uv + vec2(float(i), float(j)) * texel * 2.0).rgb;\n"
    "            b += max(s - vec3(0.60), vec3(0.0));\n"
    "        }\n"
    "    col += b * 0.085;\n"
    "    float g = dot(col, vec3(0.299, 0.587, 0.114));\n"
    "    col = mix(col, vec3(g * 0.78, g * 0.92, g * 1.22), cold);\n"
    "    col *= 1.0 - r2 * 0.85;\n"
    "    col = mix(col, vec3(0.78, 0.05, 0.08), hurt * clamp(r2 * 3.2, 0.0, 1.0));\n"
    "    col *= 0.92 + 0.08 * step(1.0, mod(gl_FragCoord.y, scale));\n"
    "    finalColor = vec4(col, 1.0);\n"
    "}\n";

static int fs(int base) {
    int v = (int)(base * uis / 10.0f + 0.5f) * 10;
    return v < 10 ? 10 : v;
}

/* pixel font: monogram (CC0), rasterized at its native 16px and drawn at integer multiples with point filtering.
   Callers keep the old size scale (10 = 1x, 20 = 2x ...), so size/10 is the pixel multiple. */
static Font ui_font;
static int ui_font_ok;
static void font_init(void) {
    ui_font = LoadFontFromMemory(".ttf", MONOGRAM_TTF, (int)MONOGRAM_TTF_LEN, 16, NULL, 0);
    if (ui_font.texture.id) {
        SetTextureFilter(ui_font.texture, TEXTURE_FILTER_POINT);
        ui_font_ok = 1;
    }
}
static int font_mul(int size) {
    int m = (size + 5) / 10;
    return m < 1 ? 1 : m;
}
static Font cur_font(void) { return ui_font_ok ? ui_font : GetFontDefault(); }

static void text(const char *s, float x, float y, int size, Color c) {
    Font f = cur_font();
    int m = font_mul(size), sh = m < 4 ? m : 3 + (m - 3) / 4;
    float px = ui_font_ok ? 16.0f * m : (float)size, sp = ui_font_ok ? 0.0f : size / 10.0f;
    Vector2 p = {floorf(x), floorf(y) - (ui_font_ok ? 2.0f * m : 0)};   /* monogram's caps sit 4 rows down in its 16px box */
    DrawTextEx(f, s, (Vector2){p.x + sh, p.y + sh}, px, sp, alphac((Color){8, 4, 12, 255}, c.a / 255.0f * 0.8f));
    DrawTextEx(f, s, p, px, sp, c);
}

static float text_w(const char *s, int size) {
    int m = font_mul(size);
    return ui_font_ok ? MeasureTextEx(ui_font, s, 16.0f * m, 0).x : MeasureTextEx(GetFontDefault(), s, (float)size, size / 10.0f).x;
}

static void text_c(const char *s, float cx, float y, int size, Color c) {
    text(s, cx - text_w(s, size) / 2, y, size, c);
}

/* centred on a point, vertically too (by the capital letters) */
static void text_cc(const char *s, float cx, float cy, int size, Color c) {
    text(s, cx - text_w(s, size) / 2, cy - 6.5f * font_mul(size), size, c);
}

static void panel(float x, float y, float w, float h) {
    float b = 3 * uis;
    DrawRectangleRounded((Rectangle){x - b, y - b, w + 2 * b, h + 2 * b}, 0.12f, 6, (Color){132, 96, 72, 235});
    DrawRectangleRounded((Rectangle){x, y, w, h}, 0.10f, 6, (Color){24, 16, 26, 232});
}

/* the game's one button look, shared with the page (web/style.css): a hard dark outline, notched corners instead of a
   radius, a lit top edge and a darker bottom edge that squashes when pressed. */
static float pix_b(void) { return fmaxf(2.0f, floorf(2.0f * uis + 0.5f)); }
static void pix_rect(float x, float y, float w, float h, float b, Color c) {
    DrawRectangle((int)(x + b), (int)y, (int)(w - 2 * b), (int)h, c);
    DrawRectangle((int)x, (int)(y + b), (int)w, (int)(h - 2 * b), c);
}
static void pix_btn(Rectangle r, Color face, Color rim, int down) {
    float b = pix_b(), d = fmaxf(2.0f, floorf(3.0f * uis + 0.5f)), oy = down ? d : 0;
    float x = r.x, y = r.y + oy, w = r.width, h = r.height - oy;
    Color shade = mixc(face, rim, 0.5f), lit = mixc(face, WHITE, 0.3f);
    pix_rect(x, y, w, h, b, rim);
    pix_rect(x + b, y + b, w - 2 * b, h - 2 * b, b, shade);
    pix_rect(x + b, y + b, w - 2 * b, h - 2 * b - (down ? 0 : d), b, face);
    DrawRectangle((int)(x + 2 * b), (int)(y + b), (int)(w - 4 * b), (int)b, lit);
}

static void bar(float x, float y, float w, float h, float frac, Color c) {
    frac = clampf(frac, 0, 1);
    DrawRectangle((int)x, (int)y, (int)w, (int)h, (Color){10, 6, 12, 255});
    DrawRectangle((int)(x + 2), (int)(y + 2), (int)((w - 4) * frac), (int)(h - 4), c);
    DrawRectangle((int)(x + 2), (int)(y + 2), (int)((w - 4) * frac), (int)((h - 4) / 3), alphac(WHITE, 0.25f));
}

/* draw a sprite; x,y is the top-left in world pixels */
static void draw_spr(int id, float x, float y, float scale, float face, Color tint, Color mask, float flash) {
    Sprite *s = &spr[id];
    float w = s->w * scale, h = s->h * scale;
    Rectangle src = {0, 0, (float)(face > 0 ? -s->w : s->w), (float)s->h};
    Rectangle dst = {floorf(x + 0.5f), floorf(y + 0.5f), w, h};
    DrawTexturePro(s->tex, src, dst, (Vector2){0, 0}, 0, tint);
    if (s->has_mask) DrawTexturePro(s->mask, src, dst, (Vector2){0, 0}, 0, mulc(mask, tint));
    if (fx_on && flash > 0.01f) DrawTexturePro(s->white, src, dst, (Vector2){0, 0}, 0, alphac(WHITE, flash * tint.a / 255.0f));
}

/* screen-space sprite (HUD) */
static void draw_spr_ui(int id, float x, float y, float scale, Color tint, Color mask) {
    Sprite *s = &spr[id];
    Rectangle src = {0, 0, (float)s->w, (float)s->h};
    Rectangle dst = {floorf(x), floorf(y), s->w * scale, s->h * scale};
    DrawTexturePro(s->tex, src, dst, (Vector2){0, 0}, 0, tint);
    if (s->has_mask) DrawTexturePro(s->mask, src, dst, (Vector2){0, 0}, 0, mask);
}

/* screen-space sprite, optionally mirrored */
static void draw_spr_ui_flip(int id, float x, float y, float scale, Color tint, int flip) {
    Sprite *s = &spr[id];
    Rectangle src = {0, 0, (float)(flip ? -s->w : s->w), (float)s->h};
    Rectangle dst = {floorf(x), floorf(y), s->w * scale, s->h * scale};
    DrawTexturePro(s->tex, src, dst, (Vector2){0, 0}, 0, tint);
}

/* a textured quad with a different light color at each corner */
static void quad(Texture2D t, int flip, float x, float y, Color c0, Color c1, Color c2, Color c3) {
    float u0 = flip ? 1.0f : 0.0f, u1 = flip ? 0.0f : 1.0f;
    rlSetTexture(t.id);
    rlBegin(RL_QUADS);
    rlNormal3f(0, 0, 1);
    rlColor4ub(c0.r, c0.g, c0.b, c0.a); rlTexCoord2f(u0, 0); rlVertex2f(x, y);
    rlColor4ub(c1.r, c1.g, c1.b, c1.a); rlTexCoord2f(u0, 1); rlVertex2f(x, y + TS);
    rlColor4ub(c2.r, c2.g, c2.b, c2.a); rlTexCoord2f(u1, 1); rlVertex2f(x + TS, y + TS);
    rlColor4ub(c3.r, c3.g, c3.b, c3.a); rlTexCoord2f(u1, 0); rlVertex2f(x + TS, y);
    rlEnd();
    rlSetTexture(0);
}

static Color light_col(float L) {
    float f, k;
    Color c;
    if (L <= 0.004f) return (Color){0, 0, 0, 255};
    f = clampf((L - 0.25f) / 0.35f, 0, 1);
    k = clampf(L * 1.12f, 0, 1);
    c = (Color){(unsigned char)((130 + 125 * f) * k), (unsigned char)((140 + 92 * f) * k),
                (unsigned char)((200 - 5 * f) * k), 255};
    return mulc(c, RULE_TINT[rule]);
}

static float tile_light(int x, int y) { return inb(x, y) ? lightv[y][x] : 0; }

static Color corner_col(int cx, int cy) {
    float L = (tile_light(cx - 1, cy - 1) + tile_light(cx, cy - 1) + tile_light(cx - 1, cy) + tile_light(cx, cy)) / 4;
    return light_col(L);
}

static Color ent_light(float fx, float fy, float floor_min) {
    int x = (int)floorf(fx + 0.5f), y = (int)floorf(fy + 0.5f);
    float L = tile_light(x, y);
    if (L < floor_min) L = floor_min;
    return light_col(L);
}

static void snap_visuals(void) {
    int x, y;
    PV.x = (float)P.x;
    PV.y = (float)P.y;
    cam.x = PV.x * TS + TS / 2;
    cam.y = PV.y * TS + TS / 2;
    for (y = 0; y < MH; y++)
        for (x = 0; x < MW; x++) lightv[y][x] = 0;
    npart = 0;
    nfade = 0;
    nring = 0;
    nproj = nsplat = nwave = nbonk = 0;
    slurp_t = 0;
}

static unsigned thash(int x, int y) {
    unsigned h = (unsigned)x * 73856093u ^ (unsigned)y * 19349663u ^ (unsigned)P.floor * 83492791u;
    h ^= h >> 13;
    h *= 0x5bd1e995u;
    return h ^ (h >> 15);
}

static void ensure_rt(void) {
    int W = GetScreenWidth(), H = GetScreenHeight();
    /* camera zoom: a whole number of screen pixels per world pixel, about 10 tiles tall on desktop. Compact
       (phone) layouts have a small canvas, so they zoom a little less in absolute terms but still closer than 2x. */
    int s = compact_ui() ? H / 100 : H / 150, nw, nh;
    if (s > W / 120) s = W / 120;
    if (s < 2) s = 2;
    nw = (W + s - 1) / s;
    nh = (H + s - 1) / s;
    if (nw != vw || nh != vh || s != pscale || rt.id == 0) {
        if (rt.id) UnloadRenderTexture(rt);
        rt = LoadRenderTexture(nw, nh);
        SetTextureFilter(rt.texture, TEXTURE_FILTER_POINT);
        vw = nw;
        vh = nh;
        pscale = s;
    }
    uis = fminf(W / 1280.0f, H / 720.0f);
    if (uis < 0.75f) uis = 0.75f;
}

static Vector2 world_to_screen(float wx, float wy) {
    return (Vector2){(wx - cam_left) * pscale, (wy - cam_top) * pscale};
}

/* ------------------------------------------------------ world update -- */

static void update_world(float dt) {
    int i, x, y, r;
    float k = clampf(dt * 16, 0, 1), fl;

    /* tween entities toward their tiles */
    PV.x += (P.x - PV.x) * k;
    PV.y += (P.y - PV.y) * k;
    PV.flash = fmaxf(0, PV.flash - dt * 5);
    PV.lunge = fmaxf(0, PV.lunge - dt * 7);
    for (i = 0; i < nmon; i++) {
        Mon *m = &mons[i];
        if (!m->alive) continue;
        m->vx += (m->x - m->vx) * k;
        m->vy += (m->y - m->vy) * k;
        m->flash = fmaxf(0, m->flash - dt * 5);
        m->lunge = fmaxf(0, m->lunge - dt * 7);
        m->bob += dt * 4;
        if (m->sleep > 0 && vis[m->y][m->x]) {
            m->zzz += dt;
            if (m->zzz > 0.8f) {
                m->zzz = 0;
                part_add(m->vx * TS + 12, m->vy * TS + 2, 8, -14, 1.2f, 0, (Color){170, 200, 255, 255}, 0, 2);
            }
        }
    }

    /* light */
    r = fov_radius();
    fl = 0.25f * sinf(g_time * 7.3f) + 0.15f * sinf(g_time * 13.1f);
    for (y = 0; y < MH; y++)
        for (x = 0; x < MW; x++) {
            float target = 0;
            if (vis[y][x]) {
                float dx = x - PV.x, dy = y - PV.y, d = sqrtf(dx * dx + dy * dy);
                float q = clampf(1 - d / (r + 0.6f + fl), 0, 1);
                target = 0.46f + 0.54f * powf(q, 0.7f);
            } else if (seen[y][x]) {
                target = 0.28f;
            }
            lightv[y][x] += (target - lightv[y][x]) * clampf(dt * 10, 0, 1);
        }

    /* camera */
    {
        float tx = PV.x * TS + TS / 2, ty = PV.y * TS + TS / 2, ck = clampf(dt * 9, 0, 1);
        cam.x += (tx - cam.x) * ck;
        cam.y += (ty - cam.y) * ck;
    }

    /* particles */
    for (i = 0; i < npart;) {
        Part *p = &parts[i];
        p->life -= dt;
        if (p->life <= 0) { parts[i] = parts[--npart]; continue; }
        p->vy += p->grav * dt;
        p->x += p->vx * dt;
        p->y += p->vy * dt;
        p->vx *= 1 - dt * 1.5f;
        i++;
    }
    for (i = 0; i < nftxt;) {
        ftxt[i].t += dt;
        if (ftxt[i].t >= ftxt[i].max) {
            memmove(&ftxt[i], &ftxt[i + 1], sizeof ftxt[0] * (size_t)(nftxt - i - 1));
            nftxt--;
            continue;
        }
        i++;
    }
    for (i = 0; i < nfade;) {
        fades[i].t += dt;
        if (fades[i].t >= 0.35f) { fades[i] = fades[--nfade]; continue; }
        i++;
    }
    for (i = 0; i < nproj;) {
        Proj *q = &projs[i];
        float p, x, y;
        q->t += dt;
        if (q->t < 0) { i++; continue; }
        p = clampf(q->t / q->dur, 0, 1);
        x = q->x0 + (q->x1 - q->x0) * p;
        y = q->y0 + (q->y1 - q->y0) * p - sinf(p * PI_F) * q->arc;
        part_add(x, y, frange(-8, 8), frange(-6, 10), 0.3f, 1.5f, fx_band_col(q->band), 120, 0);
        if (q->t >= q->dur) {
            fx_splash(q->x1, q->y1, q->dx, q->dy, q->hit, q->band);
            if (q->hit && !q->wing) fx_text(q->tx, q->ty, WHITE, 0, "%d", q->dmg);
            projs[i] = projs[--nproj];
            continue;
        }
        i++;
    }
    for (i = 0; i < nsplat;) {
        splats[i].t += dt;
        if (splats[i].t >= 2.5f) { memmove(&splats[i], &splats[i + 1], sizeof splats[0] * (size_t)(nsplat - i - 1)); nsplat--; continue; }
        i++;
    }
    if (slurp_t > 0) {
        slurp_t -= dt;
        if (frand() < dt * (fx_on ? 16 : 5)) fx_steam(PV.x * TS + 8, PV.y * TS - 5, 1);
    }
    for (i = 0; i < nring;) {
        rings[i].t += dt;
        if (rings[i].t >= 0.7f) { rings[i] = rings[--nring]; continue; }
        i++;
    }
    /* soup fx: waves and bonks age; hot puddles steam, burning foes smoulder */
    for (i = 0; i < nwave;) {
        waves[i].t += dt;
        if (waves[i].t >= 0.5f) { waves[i] = waves[--nwave]; continue; }
        i++;
    }
    for (i = 0; i < nbonk;) {
        bonks[i].t += dt;
        if (bonks[i].t >= 0.34f) { bonks[i] = bonks[--nbonk]; continue; }
        i++;
    }
    band_pulse = fmaxf(0, band_pulse - dt * 2.2f);
    if (fx_on && (state == S_PLAY || state == S_WON)) {
        int x, y;
        for (y = (int)fmaxf(0, PV.y - 8); y < MH && y < PV.y + 9; y++)
            for (x = (int)fmaxf(0, PV.x - 12); x < MW && x < PV.x + 13; x++) {
                int h = puddle_heat[y][x];
                if (h < PUDDLE_HOT || !vis[y][x]) continue;
                if (frand() < dt * (h >= 80 ? 3.2f : 1.6f))
                    part_add(x * TS + frange(4, 12), y * TS + frange(5, 10), frange(-3, 3), frange(-16, -9), frange(0.7f, 1.2f),
                             frange(1.5f, 2.6f), (Color){255, 248, 240, 105}, -5, 0);
            }
        for (i = 0; i < nmon; i++) {
            Mon *m = &mons[i];
            if (!m->alive || !m->revealed || !vis[m->y][m->x]) continue;
            if (m->burn > 0 && frand() < dt * 9)
                part_add(m->vx * TS + frange(3, 13), m->vy * TS + frange(2, 12), frange(-6, 6), frange(-26, -14), frange(0.4f, 0.8f), 1.5f,
                         frand() < 0.5f ? (Color){255, 170, 60, 255} : (Color){255, 100, 40, 255}, -10, 1);
            if (m->stuck > 0 && mon_frozen[i] && frand() < dt * 4)
                part_add(m->vx * TS + frange(2, 14), m->vy * TS + frange(2, 12), frange(-4, 4), frange(2, 10), frange(0.4f, 0.8f), 1.5f,
                         (Color){200, 236, 255, 255}, 0, 1);
        }
    }

    /* ambient particles: soup steam, rock warmth, slippery sparkle */
    if (state == S_PLAY || state == S_WON) {
        float rate = P.soup / 100.0f;
        if (frand() < rate * dt * 14)
            part_add(PV.x * TS + TS / 2 + frange(-3, 3), PV.y * TS + 10, frange(-4, 4), frange(-22, -12),
                     frange(0.7f, 1.3f), frange(1, 2), (Color){255, 245, 235, 110}, -6, 0);
    }
    for (i = 0; i < nitem; i++) {
        Item *it = &items[i];
        if (!it->alive || !vis[it->y][it->x]) continue;
        if (it->k == I_ROCK && frand() < dt * 3)
            part_add(tcx(it->x) + frange(-3, 3), tcy(it->y) + 3, frange(-3, 3), frange(-16, -8), 0.9f, 1,
                     (Color){255, 160, 80, 150}, -4, 1);
        if (it->k == I_GOLD && frand() < dt * 0.8f)
            part_add(tcx(it->x) + frange(-4, 4), tcy(it->y) + frange(0, 4), 0, -6, 0.5f, 1,
                     (Color){255, 240, 160, 255}, 0, 1);
    }

    if (frand() < dt * 7) {   /* dust motes drifting in the torchlight */
        int mx = P.x + (int)frange(-6, 7), my = P.y + (int)frange(-4, 5);
        if (inb(mx, my) && vis[my][mx] && tile[my][mx] != '#')
            part_add(mx * TS + frange(0, TS), my * TS + frange(0, TS), frange(-3, 3), frange(-4, 1), frange(2, 4), 1,
                     (Color){255, 226, 190, 70}, 0, 1);
    }
    shake = fmaxf(0, shake - dt * 22);
    hurt_flash = fmaxf(0, hurt_flash - dt * 1.6f);
    floor_fade = fmaxf(0, floor_fade - dt * 2.2f);
    banner_t += dt;
    phase_t += dt;
}

/* ------------------------------------------------------- world draw --- */

/* a solid triangle whichever way its corners wind */
static void tri(Vector2 a, Vector2 b, Vector2 c, Color col) {
    float cr = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
    if (cr < 0) DrawTriangle(a, b, c, col);
    else DrawTriangle(a, c, b, col);
}

/* an arrowhead pointing along (dx,dy), centred on cx,cy; r is its half length */
static void chevron(float cx, float cy, int dx, int dy, float r, Color c) {
    float px = (float)-dy, py = (float)dx;   /* sideways */
    tri((Vector2){cx + dx * r, cy + dy * r},
        (Vector2){cx - dx * r * 0.7f + px * r, cy - dy * r * 0.7f + py * r},
        (Vector2){cx - dx * r * 0.7f - px * r, cy - dy * r * 0.7f - py * r}, c);
}

/* a five-point star for the UI */
static void ustar(float cx, float cy, float r, Color c) {
    int i;
    Vector2 v[10];
    for (i = 0; i < 10; i++) {
        float a = -PI_F / 2 + i * PI_F / 5, rr = i % 2 ? r * 0.45f : r;
        v[i] = (Vector2){cx + cosf(a) * rr, cy + sinf(a) * rr};
    }
    for (i = 0; i < 10; i++) tri((Vector2){cx, cy}, v[i], v[(i + 1) % 10], c);
}

/* a little star with a dark edge, for dizzy ducks */
static void pstar(float cx, float cy, Color c) {
    ustar(cx, cy, 5.2f, (Color){34, 24, 38, 255});
    ustar(cx, cy, 3.8f, c);
}

/* ===================================================== floor themes === */
/* THEMES: every floor rule gets its own tiles, decor and ambience. Tiles are painted once at
   startup from the tables below. Decor is purely visual and placed from its own hash of
   seed/floor/x/y (never rnd()), so floors stay identical per seed. */

enum { FP_FLAG, FP_TILE8, FP_CHECK, FP_PLANK, FP_MOSAIC, FP_DIAMOND, FP_PAD, FP_ARROWS, FP_DISCO };
enum { WP_BRICK, WP_BLOCK, WP_PLANK };

typedef struct {
    Color f0, f1, f2;           /* floor: base, grout/shadow, highlight/accent */
    Color w0, w1, w2, w3;       /* wall face: base, mortar, top rim, band (neon / panel) */
    Color t0, t1;               /* wall top: base, specks */
    Color base, glow;           /* wall foot shadow, the lantern glow around the player */
    int fp, wp, flip, shine;
} ThemeDef;

#define RGB(r, g, b) {r, g, b, 255}
static const ThemeDef TH[R_N] = {
    /* NORMAL: the classic brick */
    {RGB(108, 90, 92), RGB(78, 64, 70), RGB(128, 112, 112), RGB(146, 106, 88), RGB(98, 70, 66), RGB(182, 144, 114), RGB(0, 0, 0),
     RGB(92, 72, 84), RGB(108, 86, 98), RGB(34, 24, 38), {255, 170, 90, 30}, FP_FLAG, WP_BRICK, 0, 0},
    /* DARK: deep violet stone */
    {RGB(52, 50, 72), RGB(34, 32, 52), RGB(70, 68, 98), RGB(66, 60, 96), RGB(44, 40, 68), RGB(104, 98, 148), RGB(0, 0, 0),
     RGB(38, 34, 60), RGB(52, 48, 80), RGB(16, 12, 28), {150, 130, 255, 26}, FP_FLAG, WP_BRICK, 0, 0},
    /* SLIPPERY: glossy ice tiles */
    {RGB(116, 170, 204), RGB(76, 124, 168), RGB(196, 230, 248), RGB(104, 156, 206), RGB(76, 122, 176), RGB(224, 246, 255), RGB(0, 0, 0),
     RGB(132, 182, 222), RGB(186, 224, 248), RGB(40, 64, 110), {200, 235, 255, 34}, FP_TILE8, WP_BLOCK, 0, 1},
    /* DISCO: lit dance floor */
    {RGB(110, 50, 160), RGB(26, 14, 44), RGB(255, 255, 255), RGB(62, 36, 104), RGB(36, 20, 66), RGB(255, 110, 210), RGB(255, 80, 200),
     RGB(46, 28, 80), RGB(70, 44, 112), RGB(20, 10, 36), {255, 120, 230, 30}, FP_DISCO, WP_BLOCK, 0, 0},
    /* GREEDY: gilded halls */
    {RGB(118, 44, 56), RGB(70, 26, 38), RGB(226, 176, 54), RGB(150, 98, 40), RGB(94, 58, 28), RGB(255, 214, 100), RGB(240, 190, 60),
     RGB(96, 60, 38), RGB(150, 108, 50), RGB(40, 22, 20), {255, 214, 90, 38}, FP_DIAMOND, WP_BRICK, 0, 0},
    /* BOUNCY: padded gym mats */
    {RGB(86, 164, 128), RGB(44, 104, 84), RGB(150, 214, 168), RGB(76, 150, 126), RGB(40, 96, 82), RGB(190, 246, 200), RGB(255, 230, 100),
     RGB(66, 146, 108), RGB(100, 190, 140), RGB(22, 54, 44), {150, 255, 150, 30}, FP_PAD, WP_BLOCK, 0, 0},
    /* HAUNTED: cold flagstones */
    {RGB(86, 100, 126), RGB(54, 66, 92), RGB(116, 132, 158), RGB(84, 98, 130), RGB(54, 64, 92), RGB(144, 164, 202), RGB(0, 0, 0),
     RGB(56, 66, 92), RGB(76, 90, 120), RGB(14, 18, 32), {150, 210, 255, 30}, FP_FLAG, WP_BRICK, 0, 0},
    /* BACKWARDS: purple arrows, upside-down walls */
    {RGB(98, 62, 124), RGB(58, 34, 80), RGB(255, 152, 96), RGB(176, 96, 76), RGB(120, 60, 60), RGB(255, 172, 112), RGB(0, 0, 0),
     RGB(108, 60, 86), RGB(146, 84, 106), RGB(36, 18, 40), {255, 150, 110, 30}, FP_ARROWS, WP_BRICK, 1, 0},
    /* COFFEE: café planks and panelling */
    {RGB(150, 98, 60), RGB(94, 58, 38), RGB(184, 128, 82), RGB(112, 72, 48), RGB(72, 44, 34), RGB(214, 172, 122), RGB(222, 198, 160),
     RGB(86, 56, 42), RGB(116, 76, 54), RGB(34, 20, 18), {255, 190, 110, 40}, FP_PLANK, WP_PLANK, 0, 0},
    /* CROWDED: cramped mosaic */
    {RGB(150, 118, 98), RGB(92, 66, 62), RGB(204, 160, 110), RGB(168, 96, 88), RGB(108, 58, 62), RGB(232, 150, 128), RGB(0, 0, 0),
     RGB(112, 70, 76), RGB(150, 100, 98), RGB(40, 22, 28), {255, 150, 130, 30}, FP_MOSAIC, WP_BRICK, 0, 0},
    /* DUCK: bathroom tile pond-side arena */
    {RGB(124, 174, 196), RGB(80, 130, 156), RGB(198, 236, 246), RGB(84, 156, 184), RGB(56, 122, 150), RGB(230, 255, 255), RGB(250, 250, 240),
     RGB(104, 184, 202), RGB(150, 214, 226), RGB(20, 52, 70), {255, 240, 170, 34}, FP_TILE8, WP_BLOCK, 0, 0},
};
#undef RGB

static Texture2D th_floor[R_N][2], th_face[R_N], th_top[R_N];

static unsigned th_hash(unsigned a, unsigned b, unsigned c) {
    unsigned h = a * 374761393u + b * 668265263u + c * 2246822519u;
    h = (h ^ (h >> 13)) * 1274126177u;
    return h ^ (h >> 16);
}

static Color th_floor_px(int r, int v, int x, int y) {
    const ThemeDef *T = &TH[r];
    int lx = x & 7, ly = y & 7;
    unsigned h = th_hash((unsigned)x, (unsigned)y, (unsigned)(r * 2 + v));
    Color c = T->f0;
    switch (T->fp) {
    case FP_FLAG: {
        char ch = v ? S_FLOOR[(y + 8) & 15][15 - x] : S_FLOOR[y][x];
        c = ch == 'A' ? T->f1 : ch == 'z' ? T->f2 : T->f0;
        break;
    }
    case FP_TILE8:
        if (lx == 7 || ly == 7) c = T->f1;
        else if (lx == 0 || ly == 0) c = mixc(T->f0, T->f2, 0.45f);
        else if (h % 17 == 0) c = mixc(T->f0, T->f2, 0.35f);
        if (T->shine && v) {
            if (x + y == 12 || x + y == 21) c = T->f2;
            else if (x + y == 13 || x + y == 22) c = mixc(c, T->f2, 0.5f);
        }
        break;
    case FP_PLANK: {
        int row = y >> 2, off = (int)(th_hash((unsigned)row, (unsigned)v, 7) % 14) + 1, ry = y & 3;
        c = mixc(T->f0, T->f1, (th_hash((unsigned)row, (unsigned)v, 3) % 5) * 0.06f);
        if (ry == 3) c = T->f1;
        else if (x == off && ry < 3) c = T->f1;
        else if (ry == 0) c = mixc(c, T->f2, 0.3f);
        else if (h % 9 == 0) c = mixc(c, T->f2, 0.55f);
        break;
    }
    case FP_MOSAIC: {
        int cx = x >> 2, cy = y >> 2;
        unsigned ch = th_hash((unsigned)cx, (unsigned)cy, (unsigned)(r * 2 + v));
        if ((x & 3) == 3 || (y & 3) == 3) c = T->f1;
        else {
            c = mixc(T->f0, ch % 4 == 0 ? T->f2 : T->f1, (ch >> 4) % 5 * 0.08f + (ch % 4 == 0 ? 0.25f : 0));
            if ((x & 3) == 0 && (y & 3) == 0) c = mixc(c, T->f2, 0.4f);
        }
        break;
    }
    case FP_DIAMOND: {
        int d = abs((x & 15) - 8) + abs((y & 15) - 8);
        if (d > 8) c = T->f1;
        else if (d == 8 || d == 7) c = mixc(T->f0, T->f2, 0.55f);
        else if (d <= 1) c = T->f2;
        else if (h % 13 == 0) c = mixc(T->f0, T->f2, 0.25f);
        if (v && d > 8 && h % 7 == 0) c = mixc(c, T->f0, 0.4f);
        break;
    }
    case FP_PAD:
        if (lx == 7 || ly == 7) c = T->f1;
        else if (lx == 0 || ly == 0) c = T->f2;
        else if (lx == 6 || ly == 6) c = mixc(T->f0, T->f1, 0.5f);
        else if (lx >= 3 && lx <= 4 && ly >= 3 && ly <= 4) c = mixc(T->f0, T->f1, 0.6f);
        else if (v && lx == 2 && ly == 2) c = T->f2;
        break;
    case FP_ARROWS: {
        int cell = ((x >> 3) + (y >> 3)) & 1, xc = 1 + abs(ly * 2 - 7) / 2;
        c = mixc(T->f0, T->f1, cell ? 0.25f : 0);
        if (lx == 7 || ly == 7) c = T->f1;
        else if (lx == xc || lx == xc + 1) c = mixc(c, T->f2, cell ? 0.3f : 0.55f);
        break;
    }
    case FP_DISCO: {
        static const Color CELL[4] = {{92, 44, 138, 255}, {170, 50, 124, 255}, {38, 116, 156, 255}, {58, 70, 156, 255}};
        unsigned ch = th_hash((unsigned)(x >> 3), (unsigned)(y >> 3), (unsigned)(v * 5 + 1));
        c = CELL[ch % 4];
        if (lx == 7 || ly == 7) c = T->f1;
        else if (lx == 0 && ly == 0) c = mixc(c, T->f2, 0.55f);
        else if (lx == 0 || ly == 0) c = mixc(c, T->f2, 0.18f);
        break;
    }
    case FP_CHECK:
        c = ((x >> 3) + (y >> 3)) & 1 ? T->f2 : T->f0;
        break;
    }
    return c;
}

/* the wall face: row 0 is the lit rim, row 15 the dark foot (flipped for BACKWARDS) */
static Color th_face_px(int r, int x, int y) {
    const ThemeDef *T = &TH[r];
    unsigned h = th_hash((unsigned)x, (unsigned)y, (unsigned)r + 40);
    Color c = T->w0;
    if (T->flip) y = 15 - y;
    if (y == 15) return T->base;
    if (y == 0) return T->w2;
    switch (T->wp) {
    case WP_BRICK: {
        char ch = S_WALLFACE[y][x];
        c = ch == 'H' ? T->w1 : T->w0;
        if (ch == 'h' && h % 11 == 0) c = mixc(c, T->w1, 0.3f);
        if (T->w3.a && (y == 6 || y == 7)) c = y == 6 ? T->w3 : mixc(T->w3, T->w1, 0.5f);   /* a gilt band */
        break;
    }
    case WP_BLOCK:
        if (y == 7 || y == 14 || x == 7 || x == 15) c = T->w1;
        else if (y == 1 || y == 8 || x == 0 || x == 8) c = mixc(T->w0, T->w2, 0.3f);
        else if (h % 13 == 0) c = mixc(T->w0, T->w2, 0.2f);
        if (T->w3.a && y == 10 && T->w3.r > 200 && T->w3.g < 120) c = T->w3;   /* neon strip */
        if (T->w3.a && T->w3.g > 240 && (y == 4 || y == 5)) c = y == 4 ? T->w3 : mixc(T->w3, T->w1, 0.5f);   /* tile trim */
        break;
    case WP_PLANK:
        if (y < 8) {
            c = (x & 3) == 3 ? T->w1 : mixc(T->w0, T->w2, ((x >> 2) & 1) * 0.1f);
            if (y == 7) c = T->w1;
            if ((x & 3) != 3 && h % 10 == 0) c = mixc(c, T->w1, 0.4f);
        } else if (y == 8) c = T->w2;
        else {
            c = T->w3;
            if (x == 7 || x == 15 || y == 14) c = mixc(T->w3, T->w1, 0.5f);
            else if (y == 9) c = mixc(T->w3, T->w2, 0.6f);
        }
        break;
    }
    return c;
}

static Color th_top_px(int r, int x, int y) {
    const ThemeDef *T = &TH[r];
    unsigned h = th_hash((unsigned)x, (unsigned)y, (unsigned)r + 90);
    Color c = T->t0;
    if (T->wp == WP_BLOCK && (x == 7 || x == 15 || y == 7 || y == 15)) return mixc(T->t0, T->t1, 0.7f);
    if (T->wp == WP_PLANK && (x & 3) == 3) return mixc(T->t0, T->t1, 0.5f);
    if (h % 12 == 0) c = T->t1;
    return c;
}

static Texture2D th_make(Color (*fn)(int, int, int, int), int r, int v) {
    Image im = GenImageColor(16, 16, BLANK);
    Texture2D t;
    int x, y;
    for (y = 0; y < 16; y++)
        for (x = 0; x < 16; x++) ImageDrawPixel(&im, x, y, fn(r, v, x, y));
    t = LoadTextureFromImage(im);
    SetTextureFilter(t, TEXTURE_FILTER_POINT);
    UnloadImage(im);
    return t;
}
static Color th_face_fn(int r, int v, int x, int y) { (void)v; return th_face_px(r, x, y); }
static Color th_top_fn(int r, int v, int x, int y) { (void)v; return th_top_px(r, x, y); }

static void build_themes(void) {
    int r;
    for (r = 0; r < R_N; r++) {
        th_floor[r][0] = th_make(th_floor_px, r, 0);
        th_floor[r][1] = th_make(th_floor_px, r, 1);
        th_face[r] = th_make(th_face_fn, r, 0);
        th_top[r] = th_make(th_top_fn, r, 0);
    }
}

static void unload_themes(void) {
    int r;
    for (r = 0; r < R_N; r++) {
        UnloadTexture(th_floor[r][0]);
        UnloadTexture(th_floor[r][1]);
        UnloadTexture(th_face[r]);
        UnloadTexture(th_top[r]);
    }
}

/* decor hash: seed + floor + rule + tile, independent of the game's rnd() */
static unsigned decor_hash(int x, int y) {
    unsigned h = (unsigned)x * 0x9E3779B1u ^ (unsigned)y * 0x85EBCA6Bu ^ (unsigned)(P.floor + 1) * 0xC2B2AE35u
               ^ (unsigned)g_seed * 0x27D4EB2Fu ^ (unsigned)(rule + 1) * 0x165667B1u;
    h ^= h >> 15;
    h *= 0x2C1B3C6Du;
    h ^= h >> 12;
    h *= 0x297A2D39u;
    return h ^ (h >> 15);
}

static void deco_rect(float x, float y, int w, int h, Color c) { DrawRectangle((int)x, (int)y, w, h, c); }

/* a little four-point glint */
static void deco_glint(float x, float y, Color c, float ph) {
    float a = fx_on ? 0.5f + 0.5f * sinf(g_time * 3 + ph) : 0.8f;
    c.a = (unsigned char)(c.a * a);
    if (c.a < 12) return;
    DrawRectangle((int)x, (int)y - 1, 1, 3, c);
    DrawRectangle((int)x - 1, (int)y, 3, 1, c);
    DrawRectangle((int)x, (int)y, 1, 1, alphac(WHITE, a));
}

/* cobwebs in the corners of haunted rooms */
static void deco_web(float px, float py, int right, Color lc) {
    Color w = mulc((Color){220, 230, 245, 170}, lc);
    float x0 = right ? px + TS - 1 : px + 1, s = right ? -1.0f : 1.0f;
    DrawLine((int)x0, (int)py + 1, (int)(x0 + s * 7), (int)py + 1, w);
    DrawLine((int)x0, (int)py + 1, (int)x0, (int)py + 8, w);
    DrawLine((int)x0, (int)py + 1, (int)(x0 + s * 6), (int)py + 7, w);
    DrawLine((int)(x0 + s * 4), (int)py + 1, (int)x0, (int)py + 5, w);
    DrawLine((int)(x0 + s * 2), (int)py + 1, (int)x0, (int)py + 3, w);
    DrawLine((int)(x0 + s * 6), (int)py + 3, (int)(x0 + s * 3), (int)py + 6, w);
}

/* sparse props that sit flat on the floor: they never block and are skipped under items */
static void draw_decor(int x, int y, float px, float py, Color lc) {
    unsigned h = decor_hash(x, y);
    int pct, kind, ox = 2 + (int)((h >> 4) % 8), oy = 3 + (int)((h >> 9) % 7);
    float fx = px + ox, fy = py + oy, ph = (float)(h & 255) * 0.1f;
    Color a, b, c;
    if (rule == R_HAUNTED && y > 0 && tile[y - 1][x] == '#' && (h % 100) < 75 &&
        (x > 0 && tile[y][x - 1] == '#')) { deco_web(px, py, 0, lc); return; }
    if (rule == R_HAUNTED && y > 0 && tile[y - 1][x] == '#' && (h % 100) < 75 &&
        (x + 1 < MW && tile[y][x + 1] == '#')) { deco_web(px, py, 1, lc); return; }
    pct = rule == R_CROWDED ? 16 : rule == R_NORMAL ? 4 : 8;
    if ((int)((h >> 14) % 100) >= pct || item_at(x, y)) return;
    kind = (int)((h >> 22) % 5);
    switch (rule) {
    case R_NORMAL:
        if (kind < 2) { deco_rect(fx, fy, 1, 2, mulc((Color){70, 120, 64, 255}, lc)); deco_rect(fx + 1, fy + 1, 1, 1, mulc((Color){96, 150, 76, 255}, lc)); deco_rect(fx - 1, fy + 1, 1, 1, mulc((Color){70, 120, 64, 255}, lc)); }
        else { a = mulc((Color){170, 164, 156, 255}, lc); deco_rect(fx, fy, 4, 1, a); deco_rect(fx - 1, fy - 1, 1, 3, a); deco_rect(fx + 4, fy - 1, 1, 3, a); }
        break;
    case R_DARK:
        if (kind < 3) {   /* glowing mushrooms */
            a = (Color){90, 210, 236, 255};
            deco_rect(fx, fy + 1, 1, 2, mulc((Color){150, 150, 190, 255}, lc));
            deco_rect(fx - 1, fy, 3, 1, a);
            deco_rect(fx + 3, fy + 2, 1, 1, (Color){90, 210, 236, 200});
        } else DrawEllipse((int)fx + 2, (int)fy + 1, 4, 2, (Color){10, 10, 24, 120});
        break;
    case R_SLIPPERY:
        if (kind < 2) {   /* a pat of butter */
            deco_rect(fx, fy, 6, 4, (Color){120, 90, 40, 255});
            deco_rect(fx, fy, 6, 3, (Color){252, 226, 120, 255});
            deco_rect(fx + 1, fy, 3, 1, (Color){255, 250, 200, 255});
        } else {          /* a slick */
            DrawEllipse((int)fx + 2, (int)fy + 1, 5, 2, (Color){236, 250, 255, 150});
            deco_rect(fx - 1, fy, 2, 1, (Color){255, 255, 255, 230});
        }
        break;
    case R_DISCO:
        if (kind < 3) deco_glint(fx, fy, (Color){255, 255, 255, 255}, ph);
        else { deco_rect(fx, fy, 1, 1, (Color){255, 120, 220, 255}); deco_rect(fx + 3, fy + 2, 1, 1, (Color){90, 230, 255, 255}); deco_rect(fx - 2, fy + 3, 1, 1, (Color){255, 230, 90, 255}); }
        break;
    case R_GREEDY:
        if (kind < 3) deco_glint(fx, fy, (Color){255, 226, 110, 255}, ph);
        else {   /* a gilded rosette inlaid in the floor */
            b = mulc((Color){214, 160, 50, 255}, lc);
            deco_rect(fx, fy - 2, 1, 1, b); deco_rect(fx - 1, fy - 1, 3, 1, b); deco_rect(fx - 2, fy, 5, 1, b);
            deco_rect(fx - 1, fy + 1, 3, 1, b); deco_rect(fx, fy + 2, 1, 1, b);
            deco_rect(fx, fy, 1, 1, mulc((Color){120, 40, 50, 255}, lc));
        }
        break;
    case R_BOUNCY:
        if (kind < 3) {   /* a trampoline spot */
            DrawEllipseLines((int)fx + 2, (int)fy + 1, 5, 3, mulc((Color){52, 130, 84, 255}, lc));
            DrawEllipse((int)fx + 2, (int)fy + 1, 3, 1.5f, mulc((Color){190, 250, 180, 160}, lc));
        } else {          /* confetti */
            deco_rect(fx, fy, 2, 1, (Color){255, 130, 150, 255}); deco_rect(fx + 3, fy + 2, 1, 2, (Color){255, 230, 100, 255}); deco_rect(fx - 2, fy + 2, 2, 1, (Color){120, 190, 255, 255});
        }
        break;
    case R_HAUNTED:
        if (kind == 0) {   /* a candle stub */
            float fl = fx_on ? sinf(g_time * 11 + ph) * 0.4f : 0;
            deco_rect(fx, fy, 2, 3, mulc((Color){226, 220, 200, 255}, lc));
            deco_rect(fx, fy - 2 + (fl > 0.2f), 1, 2, (Color){255, 200, 90, 255});
        } else if (kind == 1) { a = mulc((Color){196, 204, 214, 255}, lc); deco_rect(fx, fy, 4, 1, a); deco_rect(fx - 1, fy - 1, 1, 3, a); deco_rect(fx + 4, fy - 1, 1, 3, a); }
        else DrawEllipse((int)fx + 2, (int)fy + 1, 4, 2, (Color){110, 220, 200, 80});
        break;
    case R_BACKWARDS:
        if (kind < 3) {    /* footprints, walking the wrong way */
            a = mulc((Color){46, 26, 62, 255}, lc);
            deco_rect(fx, fy, 2, 3, a); deco_rect(fx + 3, fy + 3, 2, 3, a);
            deco_rect(fx, fy - 1, 1, 1, a); deco_rect(fx + 3, fy + 2, 1, 1, a);
        } else { b = (Color){255, 170, 110, 200}; deco_rect(fx, fy + 1, 1, 1, b); deco_rect(fx + 1, fy, 1, 3, b); deco_rect(fx + 2, fy + 1, 3, 1, b); }
        break;
    case R_COFFEE:
        if (kind < 2) {    /* a ring from somebody's mug */
            DrawEllipseLines((int)fx + 2, (int)fy + 1, 4, 2, mulc((Color){70, 38, 24, 255}, lc));
        } else if (kind == 2) {   /* a mug, with steam */
            deco_rect(fx, fy, 5, 4, mulc((Color){240, 236, 224, 255}, lc));
            deco_rect(fx + 5, fy + 1, 1, 2, mulc((Color){240, 236, 224, 255}, lc));
            deco_rect(fx, fy, 5, 1, mulc((Color){70, 38, 24, 255}, lc));
            deco_rect(fx, fy + 3, 5, 1, mulc((Color){170, 160, 150, 255}, lc));
            if (fx_on) {
                float t = fmodf(g_time * 5 + ph, 9);
                deco_rect(fx + 2 + sinf(t * 1.3f + ph), fy - 1 - t, 1, 2, (Color){255, 250, 245, (unsigned char)(150 * (1 - t / 9))});
            } else deco_rect(fx + 2, fy - 3, 1, 2, (Color){255, 250, 245, 90});
        } else if (kind == 3) {    /* spilled beans */
            c = mulc((Color){54, 30, 22, 255}, lc);
            deco_rect(fx, fy, 2, 1, c); deco_rect(fx + 3, fy + 2, 2, 1, c); deco_rect(fx + 1, fy + 3, 2, 1, c);
        } else {            /* a tiny rug */
            deco_rect(px + 2, py + 4, 12, 8, mulc((Color){120, 44, 44, 255}, lc));
            deco_rect(px + 3, py + 5, 10, 6, mulc((Color){226, 200, 150, 255}, lc));
            deco_rect(px + 4, py + 6, 8, 4, mulc((Color){120, 44, 44, 255}, lc));
        }
        break;
    case R_CROWDED:
        if (kind == 0) { a = mulc((Color){236, 232, 220, 255}, lc); deco_rect(fx, fy, 4, 3, a); deco_rect(fx + 1, fy, 1, 1, mulc((Color){150, 140, 130, 255}, lc)); }      /* paper */
        else if (kind == 1) { deco_rect(fx, fy, 5, 2, mulc((Color){220, 90, 90, 255}, lc)); deco_rect(fx + 1, fy, 1, 2, mulc((Color){250, 240, 230, 255}, lc)); deco_rect(fx + 3, fy, 1, 2, mulc((Color){250, 240, 230, 255}, lc)); }   /* sock */
        else if (kind == 2) { deco_rect(fx - 1, fy - 1, 8, 6, mulc((Color){178, 134, 86, 255}, lc)); deco_rect(fx + 2, fy - 1, 2, 6, mulc((Color){226, 200, 150, 255}, lc)); }   /* flat cardboard */
        else if (kind == 3) { a = mulc((Color){60, 46, 56, 255}, lc); deco_rect(fx, fy, 3, 2, a); deco_rect(fx + 3, fy - 1, 1, 1, a); deco_rect(fx, fy + 2, 4, 1, mulc((Color){230, 230, 226, 255}, lc)); }   /* shoe */
        else { deco_rect(fx, fy, 2, 2, mulc((Color){110, 150, 220, 255}, lc)); deco_rect(fx, fy, 1, 1, mulc((Color){230, 240, 255, 255}, lc)); }   /* button */
        break;
    case R_DUCK:
        if (kind < 2) {    /* puddle with a ripple */
            float rp = fx_on ? fmodf(g_time * 0.8f + ph, 1) : 0.3f;
            DrawEllipse((int)fx + 2, (int)fy + 1, 6, 3, (Color){70, 150, 210, 190});
            DrawEllipse((int)fx + 1, (int)fy, 3, 1.5f, (Color){160, 220, 250, 190});
            DrawEllipseLines((int)fx + 2, (int)fy + 1, 2 + rp * 4, 1 + rp * 2, (Color){230, 250, 255, (unsigned char)(150 * (1 - rp))});
        } else if (kind == 2) {   /* soap bubbles */
            DrawCircleLines((int)fx, (int)fy, 2, (Color){255, 255, 255, 220}); DrawCircleLines((int)fx + 4, (int)fy + 2, 1, (Color){255, 255, 255, 200});
            deco_rect(fx - 1, fy - 1, 1, 1, WHITE);
        } else if (kind == 3) {   /* a pool ring */
            DrawEllipse((int)fx + 3, (int)fy + 2, 6, 4, mulc((Color){240, 70, 70, 255}, lc));
            DrawEllipse((int)fx + 3, (int)fy + 2, 3, 2, mulc((Color){176, 214, 224, 255}, lc));
            deco_rect(fx + 1, fy - 2, 3, 1, mulc((Color){250, 250, 250, 255}, lc));
        } else { deco_rect(fx, fy, 5, 3, mulc((Color){120, 200, 120, 255}, lc)); deco_rect(fx, fy, 5, 1, mulc((Color){190, 240, 190, 255}, lc)); }   /* soap */
        break;
    }
}

/* disco floor cells flash colour with the light; skipped with reduced motion */
static void draw_disco_cell(int x, int y, float px, float py, float L) {
    int on = ((x + y + (int)(g_time * 3)) & 1) == 0;
    Color c;
    if (!on || L < 0.02f) return;
    c = ColorFromHSV(fmodf(g_time * 90 + (x * 17 + y * 29), 360), 0.7f, 1);
    c.a = (unsigned char)(clampf(L, 0, 1) * 46);
    DrawRectangle((int)px, (int)py, TS, TS, c);
}

/* drifting cold mist over haunted floors; drawn inside the additive glow pass */
static void draw_mist(void) {
    int i;
    for (i = 0; i < 9; i++) {
        float t = fx_on ? g_time * (5 + i % 3 * 2) : 0;
        float bx = cam_left + fmodf(i * 97.0f + t, vw + 160) - 80;
        float by = cam_top + fmodf(i * 53.0f, (float)vh) + (fx_on ? sinf(g_time * 0.4f + i) * 8 : 0);
        float r = 46 + (i % 3) * 14;
        DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){bx - r, by - r * 0.6f, r * 2, r * 1.2f}, (Vector2){0, 0}, 0, (Color){130, 170, 210, 34});
    }
}

static void draw_entity_shadow(float px, float py, float w) {
    DrawEllipse((int)px, (int)py, w, 2.5f, (Color){0, 0, 0, 90});
}

/* ================================================ soup fx: drawing === */

static const Color FX_INK = {34, 24, 38, 255};

/* a filled blob: a fan of triangles round (cx,cy), radii from the per-tile hash so every puddle has its own outline */
#define BLOB_N 10
static void blob_pts(Vector2 *pt, float cx, float cy, float rx, float ry, unsigned h, float wob) {
    int k;
    for (k = 0; k < BLOB_N; k++) {
        float a = k * 2 * PI_F / BLOB_N, r = 1.0f - wob * (float)((h >> (k * 3)) & 7) / 7.0f;
        pt[k] = (Vector2){cx + cosf(a) * rx * r, cy + sinf(a) * ry * r};
    }
}
static void blob_fill(float cx, float cy, const Vector2 *pt, Color c) {
    int k;
    for (k = 0; k < BLOB_N; k++) tri((Vector2){cx, cy}, pt[k], pt[(k + 1) % BLOB_N], c);
}

/* Puddle art. Irregular blob per tile (hash of x,y,floor), a dark rim so it reads on any floor, a sheen,
   steam wisps when hot, gloopy bubbles when lukewarm. It shrinks and fades as it cools. Under an item or
   monster it thins out so they stay readable. */
static void draw_puddle(float px, float py, int heat, int tx, int ty) {
    int band = heat >= 80 ? B_SCALD : heat >= PUDDLE_HOT ? B_HOT : heat >= PUDDLE_LUKE ? B_LUKE : B_COLD;
    unsigned h = thash(tx * 7 + 3, ty * 5 + 11);
    float fresh = clampf((float)heat / 100.0f, 0, 1), sz = 0.50f + 0.50f * sqrtf(fresh);
    float cx = px + 8 + (float)((h >> 20) % 3) - 1, cy = py + 9 + (float)((h >> 24) % 3) - 1;
    float rx = 7.6f * sz, ry = 4.9f * sz, a = 0.6f + 0.4f * fresh;
    Vector2 body[BLOB_N], surf[BLOB_N], sheen[BLOB_N];
    Color lc = ent_light((float)tx, (float)ty, 0.8f);
    Color edge_c = mulc(mixc(FX_DARK[band], FX_BAND[band], 0.25f), lc), surf_c = mulc(mixc(FX_BAND[band], FX_DARK[band], 0.12f), lc);
    Color rim_c = mulc(FX_DARK[band], lc), hi_c = mulc(mixc(FX_BAND[band], WHITE, 0.55f), lc);
    float wob = band == B_LUKE ? 0.34f : 0.22f;
    float tm = fx_on ? g_time : 0;
    if (item_at(tx, ty) || mon_at(tx, ty) || (tx == P.x && ty == P.y)) a *= 0.6f;
    blob_pts(body, cx, cy, rx, ry, h, wob);
    blob_fill(cx, cy + 1, body, alphac(FX_INK, 0.4f * a));    /* contact shadow, so it reads on any floor */
    blob_fill(cx, cy, body, alphac(edge_c, a));              /* the darker rim of the pool */
    blob_pts(surf, cx, cy - 0.4f, rx * 0.84f, ry * 0.74f, h >> 5, wob * 0.7f);
    blob_fill(cx, cy - 0.4f, surf, alphac(surf_c, a));        /* the lit surface */
    {   /* rim line */
        int k;
        for (k = 0; k < BLOB_N; k++) DrawLineV(body[k], body[(k + 1) % BLOB_N], alphac(rim_c, a));
    }
    /* sheen: a lighter inner blob up and left, and a couple of glints */
    blob_pts(sheen, cx - rx * 0.2f, cy - ry * 0.3f, rx * 0.5f, ry * 0.32f, h >> 3, wob);
    blob_fill(cx - rx * 0.2f, cy - ry * 0.3f, sheen, alphac(hi_c, 0.7f * a));
    DrawRectangle((int)(cx - rx * 0.55f), (int)(cy - ry * 0.45f), 2, 1, alphac(WHITE, 0.9f * a));
    DrawRectangle((int)(cx - rx * 0.55f) + 3, (int)(cy - ry * 0.45f) + 1, 1, 1, alphac(WHITE, 0.65f * a));
    if (band == B_LUKE) {   /* gloop: a bubble that swells and a thick strand */
        float bp = fmodf(tm * 0.6f + (float)(h & 15) * 0.1f, 1.0f);
        float br = 0.8f + bp * 1.4f;
        DrawCircleLines((int)(cx + rx * 0.3f), (int)(cy + ry * 0.1f), br, alphac(hi_c, (1 - bp) * a));
        DrawRectangle((int)(cx + rx * 0.45f), (int)(cy + ry * 0.55f), 2, 1, alphac(rim_c, a));
    } else if (band == B_SCALD && fx_on) {   /* a little bubbling */
        float bp = fmodf(tm * 1.5f + (float)(h & 15) * 0.2f, 1.0f);
        DrawRectangle((int)(cx + rx * 0.35f), (int)(cy - bp * 3), 1, 1, alphac((Color){255, 230, 150, 255}, (1 - bp) * a));
    }
    if (band <= B_HOT) {   /* steam: moving wisps are particles; reduced motion keeps one still curl */
        if (!fx_on) {
            DrawRectangle((int)cx - 1, (int)cy - 5, 1, 2, (Color){255, 248, 240, 120});
            DrawRectangle((int)cx, (int)cy - 7, 1, 2, (Color){255, 248, 240, 90});
        }
    }
}

/* little status marks on a monster at sprite position px,py (w,h its size): flames, goo, ice, dizzy swirl */
static void draw_mon_status(const Mon *m, int idx, float px, float py, float w, float h) {
    float cx = px + w / 2, foot = py + h, tm = fx_on ? g_time : 0;
    int k;
    if (!m->revealed) return;
    if (m->burn > 0) {   /* three little flames licking up the body; reduced motion keeps them still */
        for (k = 0; k < 3; k++) {
            float fl = fx_on ? sinf(tm * 14 + k * 2.1f + idx) : 0, fx0 = cx + (k - 1) * w * 0.3f, fy0 = foot - h * 0.45f - (k == 1 ? 3 : 0);
            float fh = 4 + fl * 1.2f + (k == 1 ? 2 : 0);
            DrawRectangle((int)fx0 - 1, (int)(fy0 - fh) - 1, 4, (int)fh + 3, FX_INK);
            DrawRectangle((int)fx0, (int)(fy0 - fh), 2, (int)fh + 1, (Color){255, 96, 36, 255});
            DrawRectangle((int)fx0, (int)(fy0 - fh * 0.5f), 1, (int)(fh * 0.5f) + 1, (Color){255, 220, 90, 255});
        }
    }
    if (m->stuck > 0 && !mon_frozen[idx]) {   /* goo on the feet: a glob with drips */
        Color g = {212, 204, 150, 255}, gd = {136, 126, 84, 255};
        float gy = foot, gw = w * 0.62f;
        DrawRectangle((int)(cx - gw / 2) - 1, (int)gy - 3, (int)gw + 2, 4, FX_INK);
        DrawRectangle((int)(cx - gw / 2), (int)gy - 3, (int)gw, 3, g);
        DrawRectangle((int)(cx - gw / 2) + 2, (int)gy - 4, (int)gw - 4, 1, g);
        DrawRectangle((int)(cx - gw / 2), (int)gy - 1, (int)gw, 1, gd);
        DrawRectangle((int)(cx - gw / 2) + 1, (int)gy - 3, 2, 1, alphac(WHITE, 0.8f));
        DrawRectangle((int)(cx - gw / 2) + 1, (int)gy + 1, 2, 2, g);   /* drips */
        DrawRectangle((int)(cx + gw / 2) - 3, (int)gy + 1, 1, 3, g);
    }
    if (m->stuck > 0 && mon_frozen[idx]) {   /* ice: pale crystals round the feet and glints */
        Color ic = {196, 234, 255, 255}, id = {104, 164, 220, 255};
        for (k = 0; k < 4; k++) {
            float ix = cx + (k - 1.5f) * w * 0.26f, ih = (k % 2) ? 7 : 4;
            tri((Vector2){ix - 2, foot}, (Vector2){ix + 2, foot}, (Vector2){ix, foot - ih}, FX_INK);
            tri((Vector2){ix - 1, foot - 1}, (Vector2){ix + 1, foot - 1}, (Vector2){ix, foot - ih + 1}, (k % 2) ? ic : id);
        }
        if (!fx_on || fmodf(tm * 2 + idx, 1.5f) < 0.2f) pstar(px + w * 0.8f, py + h * 0.25f, (Color){220, 245, 255, 255});
    }
    if (m->fear > 0) {   /* dizzy: a purple swirl over the head */
        float hy = py - 5;
        for (k = 0; k < 4; k++) {
            float a = (fx_on ? tm * 5 : 0.5f) + k * 1.57f;
            DrawRectangle((int)(cx + cosf(a) * 6) - 1, (int)(hy + sinf(a) * 2) - 1, 3, 3, FX_INK);
            DrawRectangle((int)(cx + cosf(a) * 6), (int)(hy + sinf(a) * 2), 2, 2, k == 0 ? (Color){255, 190, 240, 255} : (Color){196, 120, 236, 255});
        }
    }
}

/* the splash wave and ladle bonk, drawn above the floor in world space */
static void draw_soup_waves(void) {
    static const int AX[8] = {-1, 0, 1, -1, 1, -1, 0, 1}, AY[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
    int i, k;
    for (i = 0; i < nwave; i++) {
        SoupWave *w = &waves[i];
        float p = w->t / 0.5f, e = 1 - (1 - p) * (1 - p), r = 4 + e * 17;
        Color c = FX_BAND[w->band], dk = FX_DARK[w->band];
        float thick = (w->band == B_LUKE ? 3.4f : 2.4f) * (1 - p) + 0.6f;
        DrawRing((Vector2){w->x, w->y}, r - thick, r, 0, 360, 40, alphac(c, (1 - p) * 0.9f));
        DrawRing((Vector2){w->x, w->y}, fmaxf(0, r - thick - 3), r - thick - 1.5f, 0, 360, 40, alphac(mixc(c, WHITE, 0.5f), (1 - p) * 0.5f));
        for (k = 0; k < 8; k++) {
            float l = (AX[k] && AY[k]) ? 1.414f : 1.0f, d = e * 16 * l + 3;
            float sag = w->band == B_COLD ? p * p * 7 : w->band == B_LUKE ? p * 3 : 0;
            float bx = w->x + AX[k] * d, by = w->y + AY[k] * d + sag, br = (w->band == B_COLD ? 2.0f : 3.4f) * (1 - p * 0.55f);
            if (w->band == B_LUKE)   /* a gloopy string back to the middle */
                DrawLineEx((Vector2){w->x + AX[k] * d * 0.5f, w->y + AY[k] * d * 0.5f}, (Vector2){bx, by}, 1.6f * (1 - p), alphac(c, 0.8f));
            DrawCircle((int)bx, (int)by, br + 1, alphac(FX_INK, (1 - p) * 0.85f));
            DrawCircle((int)bx, (int)by, br, alphac(c, 1 - p * 0.5f));
            DrawRectangle((int)bx - 1, (int)by - 1, 1, 1, alphac(WHITE, 1 - p));
            (void)dk;
        }
    }
    for (i = 0; i < nbonk; i++) {   /* a ladle swing, then a comic impact star */
        SoupBonk *b = &bonks[i];
        float t = b->t, base = atan2f((float)b->dy, (float)b->dx), ang, ox = PV.x * TS + 8, oy = PV.y * TS + 6;
        float sw = fx_on ? clampf(t / 0.12f, 0, 1) : 1, len = 13;
        Vector2 a, e;
        int dir = b->dx < 0 || (b->dx == 0 && b->dy < 0) ? -1 : 1;
        if (!fx_on && t > 0.22f) continue;
        sw = sw * sw;
        ang = base - dir * (1.9f * (1 - sw));   /* raised behind, then down onto the foe */
        a = (Vector2){ox + cosf(ang) * 3, oy + sinf(ang) * 3};
        e = (Vector2){ox + cosf(ang) * len, oy + sinf(ang) * len};
        DrawLineEx(a, e, 3.4f, FX_INK);
        DrawLineEx(a, e, 1.6f, (Color){196, 140, 84, 255});
        DrawCircle((int)e.x, (int)e.y, 3.6f, FX_INK);
        DrawCircle((int)e.x, (int)e.y, 2.6f, (Color){204, 210, 222, 255});
        DrawRectangle((int)e.x - 1, (int)e.y - 1, 1, 1, (Color){250, 252, 255, 255});
        if (fx_on && t > 0.1f) {
            float q = clampf((t - 0.1f) / 0.22f, 0, 1), sr = 5 + q * 8;
            Color sc = alphac((Color){255, 214, 70, 255}, 1 - q * 0.8f), so = alphac(FX_INK, 1 - q);
            for (k = 0; k < 4; k++) {
                float sa = k * 1.5708f + 0.4f;
                Vector2 p0 = {b->x + cosf(sa) * sr * 0.5f, b->y + sinf(sa) * sr * 0.5f}, p1 = {b->x + cosf(sa) * sr, b->y + sinf(sa) * sr};
                DrawLineEx(p0, p1, 3.6f, so);
                DrawLineEx(p0, p1, 1.8f, sc);
            }
            pstar(b->x, b->y - 3, alphac((Color){255, 244, 170, 255}, 1 - q));
        }
    }
}

typedef struct { float y; int kind, idx; } DrawRef;

static int cmp_ref(const void *a, const void *b) {
    float d = ((const DrawRef *)a)->y - ((const DrawRef *)b)->y;
    return d < 0 ? -1 : d > 0;
}

static void draw_world(void) {
    int x, y, i, x0, x1, y0, y1, nref = 0;
    static DrawRef refs[MAXMON + 2];
    float sx = shake > 0 ? frange(-shake, shake) : 0, sy = shake > 0 ? frange(-shake, shake) : 0;
    Camera2D c;

    {   /* keep the view inside the map (plus a tile of slack) so the edge never fills the screen */
        float cl = cam.x - vw / 2.0f, ct = cam.y - vh / 2.0f;
        float lo_x = -(float)TS, hi_x = MW * TS - vw + (float)TS, lo_y = -(float)TS, hi_y = MH * TS - vh + (float)TS;
        cl = hi_x < lo_x ? (MW * TS - vw) / 2.0f : clampf(cl, lo_x, hi_x);
        ct = hi_y < lo_y ? (MH * TS - vh) / 2.0f : clampf(ct, lo_y, hi_y);
        cam_left = floorf(cl + sx);
        cam_top = floorf(ct + sy);
    }
    c.offset = (Vector2){0, 0};
    c.target = (Vector2){cam_left, cam_top};
    c.rotation = 0;
    c.zoom = 1;

    x0 = (int)floorf(cam_left / TS) - 1;
    y0 = (int)floorf(cam_top / TS) - 1;
    x1 = x0 + vw / TS + 3;
    y1 = y0 + vh / TS + 3;
    if (x0 < 0) x0 = 0;
    if (y0 < 0) y0 = 0;
    if (x1 > MW) x1 = MW;
    if (y1 > MH) y1 = MH;

    BeginTextureMode(rt);
    ClearBackground((Color){6, 4, 8, 255});
    BeginMode2D(c);

    /* tiles */
    for (y = y0; y < y1; y++)
        for (x = x0; x < x1; x++) {
            Color c0, c1, c2, c3;
            float px = (float)(x * TS), py = (float)(y * TS);
            char t = tile[y][x];
            if (!seen[y][x] && lightv[y][x] < 0.01f) continue;
            c0 = corner_col(x, y);
            c1 = corner_col(x, y + 1);
            c2 = corner_col(x + 1, y + 1);
            c3 = corner_col(x + 1, y);
            if (t == '#') {
                if (y + 1 < MH && tile[y + 1][x] != '#') quad(th_face[rule], 0, px, py, c0, c1, c2, c3);
                else {
                    quad(th_top[rule], (int)(thash(x, y) & 1), px, py, c0, c1, c2, c3);
                    /* rim where the wall top meets open floor */
                    if (x > 0 && tile[y][x - 1] != '#') DrawRectangle((int)px, (int)py, 1, TS, mulc(TH[rule].w2, c0));
                    if (x + 1 < MW && tile[y][x + 1] != '#') DrawRectangle((int)px + TS - 1, (int)py, 1, TS, mulc(TH[rule].w2, c3));
                    if (y > 0 && tile[y - 1][x] != '#') DrawRectangle((int)px, (int)py, TS, 1, mulc(TH[rule].w2, c0));
                }
            } else {
                unsigned h = thash(x, y);
                quad(th_floor[rule][TH[rule].shine ? ((h >> 1) % 5 == 0) : (int)((h >> 1) & 1)], (int)(h & 1), px, py, c0, c1, c2, c3);
                if ((h >> 4) % 11 == 0) {   /* a pebble or crack now and then */
                    Color pc = mulc((Color){110, 96, 100, 255}, c0);
                    DrawRectangle((int)px + 3 + (int)((h >> 8) % 9), (int)py + 3 + (int)((h >> 12) % 9), 2, 1, pc);
                }
                if (y > 0 && tile[y - 1][x] == '#')
                    DrawRectangleGradientV((int)px, (int)py, TS, 7, (Color){0, 0, 0, 120}, (Color){0, 0, 0, 0});
                if (x > 0 && tile[y][x - 1] == '#')
                    DrawRectangleGradientH((int)px, (int)py, 4, TS, (Color){0, 0, 0, 70}, (Color){0, 0, 0, 0});
                if (t == '.') draw_decor(x, y, px, py, c0);
                if (puddle_heat[y][x]) draw_puddle(px, py, puddle_heat[y][x], x, y);
                if (rule == R_DISCO && fx_on) draw_disco_cell(x, y, px, py, lightv[y][x]);
                if (t == '>') quad(spr[SP_STAIRS].tex, 0, px, py, c0, c1, c2, c3);
                if (t == 'V') {
                    Color lc = ent_light((float)x, (float)y, 0);
                    draw_entity_shadow(px + 8, py + 15, 7);
                    draw_spr(SP_VEND, px, py - 2, 1, -1, lc, WHITE, 0);
                }
            }
        }

    /* soup splashes drying on the floor */
    for (i = 0; i < nsplat; i++) {
        Splat *sp = &splats[i];
        float a = (1 - sp->t / 2.5f) * 0.75f;
        Color dc = mulc(sp->c, (Color){190, 170, 150, 255});
        int k, ix = (int)sp->x, iy = (int)sp->y;
        DrawRectangle(ix - 3, iy - 1, 6, 3, alphac(dc, a));
        DrawRectangle(ix - 2, iy - 2, 4, 5, alphac(dc, a));
        for (k = 0; k < 6; k++) {
            unsigned h = sp->seed * 2654435761u + (unsigned)k * 40503u;
            DrawRectangle(ix + (int)((h >> 3) % 13) - 6, iy + (int)((h >> 9) % 9) - 4, 1 + (int)((h >> 14) & 1), 1 + (int)((h >> 15) & 1), alphac(dc, a));
        }
    }

    /* items */
    for (i = 0; i < nitem; i++) {
        Item *it = &items[i];
        float bob, px, py;
        Color lc;
        if (!it->alive || !seen[it->y][it->x]) continue;
        if (it->x < x0 || it->x >= x1 || it->y < y0 || it->y >= y1) continue;
        bob = vis[it->y][it->x] ? roundf(sinf(g_time * 3 + it->x * 1.7f) * 1.0f) : 0;
        px = (float)(it->x * TS);
        py = it->y * TS + bob;
        lc = ent_light((float)it->x, (float)it->y, 0);
        draw_entity_shadow(px + 8, it->y * TS + 14.0f, 5);
        if (it->k == I_POTION) draw_spr(SP_POTION, px, py, 1, -1, lc, LOOK_COL[pot_look[it->sub]], 0);
        else if (it->k == I_INGREDIENT) draw_spr(SP_ING + it->sub, px, py, 1, -1, lc, WHITE, 0);
        else draw_spr(ITEM_SPR[it->k], px, py, 1, -1, lc, WHITE, 0);
    }

    /* entities, sorted by depth */
    for (i = 0; i < nmon; i++) {
        Mon *m = &mons[i];
        if (!m->alive || !vis[m->y][m->x]) continue;
        refs[nref].y = m->vy;
        refs[nref].kind = 1;
        refs[nref].idx = i;
        nref++;
    }
    if (state != S_DEAD) {
        refs[nref].y = PV.y;
        refs[nref].kind = 0;
        refs[nref].idx = 0;
        nref++;
    }
    qsort(refs, (size_t)nref, sizeof refs[0], cmp_ref);
    for (i = 0; i < nref; i++) {
        if (refs[i].kind == 0) {
            float lo = sinf(PV.lunge * PI_F) * 5, hop = 0;
            float dd = fabsf(P.x - PV.x) + fabsf(P.y - PV.y);
            float px, py;
            Color lc = ent_light(PV.x, PV.y, 0.8f);
            if (dd > 0.02f && dd < 1) hop = -sinf(dd * PI_F) * 2.5f;
            px = PV.x * TS + PV.ldx * lo;
            py = PV.y * TS + PV.ldy * lo + hop + roundf(sinf(g_time * 2.5f) * 0.5f);
            draw_entity_shadow(PV.x * TS + 8, PV.y * TS + 15, 6);
            draw_spr(SP_PLAYER, px, py, 1, PV.face > 0 ? -1.0f : 1.0f, lc, soup_col(P.soup), PV.flash);
            if (slurp_t > 0) {   /* the bowl goes up for a slurp */
                float q = 1 - slurp_t / 0.9f, lift = fx_on ? sinf(q * PI_F) * 3 : 1.5f;
                float bx = PV.x * TS + 2, by = PV.y * TS - 10 - lift;
                draw_spr(SP_BOWL, bx, by, 0.5f, -1, lc, soup_col(P.soup), 0);
                {   /* slurp: ripples on the surface, drops up to the mouth, a curl of steam over the rim */
                    Color sc = soup_col(P.soup);
                    int d;
                    DrawRectangle((int)bx + 2, (int)by + 1, 4, 1, alphac(WHITE, 0.55f));
                    if (fx_on) {
                        for (d = 0; d < 3; d++) {
                            float f = fmodf(q * 2.5f + d * 0.33f, 1);
                            DrawRectangle((int)(bx + 3 + sinf(f * 6 + d) * 1.5f), (int)(by + 4 + f * (PV.y * TS + 3 - by - 4)), 1, 2, alphac(sc, 1 - f));
                        }
                        for (d = 0; d < 2; d++) {
                            float f = fmodf(g_time * 1.6f + d * 0.5f, 1);
                            DrawRectangle((int)(bx + 2 + d * 3 + sinf(g_time * 6 + d * 3) * 1.5f), (int)(by - 2 - f * 7), 1, 2, alphac((Color){255, 248, 240, 255}, (1 - f) * 0.7f));
                        }
                    } else {
                        DrawRectangle((int)bx + 3, (int)by - 3, 1, 2, (Color){255, 248, 240, 170});
                    }
                }
            }
            if (P.confused > 0) {
                int s;
                for (s = 0; s < 3; s++) {
                    float a = g_time * 4 + s * 2.09f;
                    DrawRectangle((int)(PV.x * TS + 8 + cosf(a) * 6), (int)(PV.y * TS - 1 + sinf(a) * 2), 2, 2,
                                  (Color){255, 230, 120, 255});
                }
            }
        } else {
            Mon *m = &mons[refs[i].idx];
            const MonType *T = &MT[m->t];
            float lo = sinf(m->lunge * PI_F) * 5, scale = m->t == M_DUCK ? 2.0f : 1.0f;
            float w = 16 * scale, h = 16 * scale, px, py, hover = 0;
            int sid = T->spr;
            Color lc = ent_light(m->vx, m->vy, 0), mk = WHITE;
            if (m->t == M_BAT) hover = -3 + sinf(m->bob * 3) * 2;
            if (m->t == M_DRAFT) {
                hover = -2 + sinf(m->bob) * 2;
                lc.a = 190;
            }
            if (m->t == M_SLIME) h = 16 + sinf(m->bob * 1.5f) * 0.8f;
            if (!m->revealed) {
                sid = SP_POTION;
                mk = LOOK_COL[pot_look[m->look]];
                if (fmodf(g_time + m->bob, 5.0f) < 0.12f) hover = -1;   /* the tiniest tell */
            }
            if (rule == R_DISCO && m->revealed)
                lc = mulc(lc, ColorFromHSV(fmodf(g_time * 90 + m->x * 40, 360), 0.35f, 1));
            px = m->vx * TS + TS / 2 - w / 2 + m->ldx * lo;
            py = m->vy * TS + TS - h + m->ldy * lo + hover;
            if (m->t == M_DUCK && m->tele && fx_on) px += sinf(g_time * 38) * 1.2f;   /* the tail feathers wiggle */
            if (m->t == M_DUCK && m->cstun > 0) py += 2 + (fx_on ? sinf(g_time * 5) : 0);
            draw_entity_shadow(m->vx * TS + 8, m->vy * TS + 15, w * 0.38f);
            if (m->revealed && m->stuck > 0 && mon_frozen[refs[i].idx]) lc = mulc(lc, (Color){150, 200, 255, 255});
            else if (m->revealed && m->stuck > 0) lc = mulc(lc, (Color){255, 240, 210, 255});
            draw_spr(sid, px, py, scale, m->revealed ? m->face : -1, lc, mk, m->flash);
            draw_mon_status(m, refs[i].idx, px, py, w, h);
            if (m->t == M_DUCK && m->cstun > 0) {   /* dizzy stars circle the head */
                float hx = m->face > 0 ? px + w - 12 : px + 12;
                int s;
                for (s = 0; s < 3; s++) {
                    float a = (fx_on ? g_time * 3.5f : 0.4f) + s * 2.09f;
                    pstar(hx + cosf(a) * 12, py - 2 + sinf(a) * 4, s == 1 ? (Color){255, 248, 170, 255} : (Color){255, 214, 70, 255});
                }
            }
            if (m->t == M_DUCK && m->tele) {   /* a warning sign over the beak */
                float wx = m->face > 0 ? px + w - 12 : px + 12, wy = py - 7 + (fx_on ? sinf(g_time * 12) * 1.5f : 0);
                DrawTriangle((Vector2){wx - 7, wy + 6}, (Vector2){wx + 7, wy + 6}, (Vector2){wx, wy - 7}, (Color){34, 24, 38, 255});
                DrawTriangle((Vector2){wx - 5, wy + 5}, (Vector2){wx + 5, wy + 5}, (Vector2){wx, wy - 4}, (Color){255, 214, 70, 255});
                DrawRectangle((int)wx, (int)wy - 1, 1, 3, (Color){34, 24, 38, 255});
                DrawRectangle((int)wx, (int)wy + 3, 1, 1, (Color){34, 24, 38, 255});
            }
            if (m->revealed && m->hp < m->maxhp) {
                float f = (float)m->hp / m->maxhp, bw = w * 0.75f, bx = m->vx * TS + TS / 2 - bw / 2, by = py - 3;
                DrawRectangle((int)bx, (int)by, (int)bw, 2, (Color){20, 10, 14, 220});
                DrawRectangle((int)bx, (int)by, (int)(bw * f), 2, f > 0.5f ? (Color){120, 220, 90, 255} : (Color){240, 90, 70, 255});
            }
        }
    }

    /* corpses fading out */
    for (i = 0; i < nfade; i++) {
        FadeSpr *f = &fades[i];
        float p = f->t / 0.35f, sc = f->scale * (1 + p * 0.3f), w = 16 * sc, h = 16 * sc * (1 - p * 0.5f);
        draw_spr(f->spr, f->x * TS + 8 - w / 2, f->y * TS + 16 - h, sc, f->face, alphac(WHITE, 1 - p), WHITE, 1 - p);
    }

    /* glows */
    BeginBlendMode(BLEND_ADDITIVE);
    {
        float r = (fov_radius() + 1.5f) * TS * 1.3f;
        DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64},
                       (Rectangle){PV.x * TS + 8 - r, PV.y * TS + 8 - r, r * 2, r * 2}, (Vector2){0, 0}, 0,
                       TH[rule].glow);
        if (rule == R_HAUNTED) draw_mist();
    }
    for (i = 0; i < nitem; i++) {
        Item *it = &items[i];
        Color gc;
        float r;
        if (!it->alive || !vis[it->y][it->x]) continue;
        if (it->k == I_ROCK) { gc = (Color){255, 120, 40, 120}; r = 14 + sinf(g_time * 3) * 2; }
        else if (it->k == I_POTION) { gc = alphac(LOOK_COL[pot_look[it->sub]], 0.35f); r = 10; }
        else if (it->k == I_GOLD) { gc = (Color){255, 210, 80, 50}; r = 10; }
        else continue;
        DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){tcx(it->x) - r, tcy(it->y) - r, r * 2, r * 2},
                       (Vector2){0, 0}, 0, gc);
    }
    for (y = y0; y < y1; y++)
        for (x = x0; x < x1; x++) {
            float r;
            if (!vis[y][x]) continue;
            if (tile[y][x] == '>') {
                r = 16 + sinf(g_time * 2) * 3;
                DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){tcx(x) - r, tcy(y) - r, r * 2, r * 2},
                               (Vector2){0, 0}, 0, (Color){120, 200, 255, 70});
            } else if (tile[y][x] == 'V') {
                r = 18;
                DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){tcx(x) - r, tcy(y) - r, r * 2, r * 2},
                               (Vector2){0, 0}, 0, (Color){90, 140, 255, 90});
            }
        }
    /* the duck's waddle line: arrows along every tile it will cross */
    for (i = 0; i < nmon; i++) {
        Mon *m = &mons[i];
        int k;
        if (!m->alive || !m->tele || !vis[m->y][m->x]) continue;
        for (k = 1; k <= 6 && walkable(m->x + m->cdx * k, m->y + m->cdy * k); k++) {
            float tx0 = (float)((m->x + m->cdx * k) * TS), ty0 = (float)((m->y + m->cdy * k) * TS);
            float wave = fx_on ? 0.5f + 0.5f * sinf(g_time * 10 - k * 1.2f) : 0.6f;
            DrawRectangle((int)tx0 + 1, (int)ty0 + 1, TS - 2, TS - 2, (Color){255, 60, 50, (unsigned char)(50 + wave * 40)});
            DrawRectangleLines((int)tx0 + 1, (int)ty0 + 1, TS - 2, TS - 2, (Color){255, 90, 70, (unsigned char)(110 + wave * 80)});
            chevron(tx0 + 8, ty0 + 8, m->cdx, m->cdy, 6.5f, (Color){34, 12, 18, 255});
            chevron(tx0 + 8, ty0 + 8, m->cdx, m->cdy, 4.5f, (Color){255, 226, 120, (unsigned char)(170 + wave * 85)});
        }
    }
    /* the flung soup, mid-air */
    for (i = 0; i < nproj; i++) {
        Proj *q = &projs[i];
        float p = clampf(q->t / q->dur, 0, 1), x = q->x0 + (q->x1 - q->x0) * p;
        float gy = q->y0 + (q->y1 - q->y0) * p, y = gy - sinf(p * PI_F) * q->arc;
        Color sc = FX_BAND[q->band];
        if (q->t < 0) continue;
        DrawEllipse((int)x, (int)gy + 3, 3, 1.5f, (Color){0, 0, 0, 90});
        if (q->wing) {   /* a bat wing carries it on: dark flapping wings either side */
            float fl = sinf(q->t * 40) * 2;
            tri((Vector2){x, y}, (Vector2){x - 8, y - 3 - fl}, (Vector2){x - 5, y + 2}, FX_INK);
            tri((Vector2){x, y}, (Vector2){x + 8, y - 3 - fl}, (Vector2){x + 5, y + 2}, FX_INK);
            tri((Vector2){x, y}, (Vector2){x - 6, y - 2 - fl}, (Vector2){x - 4, y + 1}, (Color){92, 70, 110, 255});
            tri((Vector2){x, y}, (Vector2){x + 6, y - 2 - fl}, (Vector2){x + 4, y + 1}, (Color){92, 70, 110, 255});
        }
        DrawCircle((int)x, (int)y, 4.5f, (Color){34, 24, 38, 255});
        DrawCircle((int)x, (int)y, 3.5f, sc);
        DrawRectangle((int)x - 2, (int)y - 2, 2, 1, (Color){230, 200, 170, 255});
        DrawRectangle((int)(x + cosf(q->t * 30) * 5), (int)(y + sinf(q->t * 30) * 4), 2, 2, (Color){112, 190, 82, 255});
        DrawRectangle((int)(x - cosf(q->t * 30) * 5), (int)(y - sinf(q->t * 30) * 4), 2, 2, (Color){240, 120, 50, 255});
    }
    for (i = 0; i < nmon; i++) {
        Mon *m = &mons[i];
        if (!m->alive || !vis[m->y][m->x]) continue;
        if (m->t == M_CHAIR) {
            DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){m->vx * TS + 2, m->vy * TS - 3, 12, 12},
                           (Vector2){0, 0}, 0, (Color){255, 40, 40, 150});
        } else if (m->t == M_DRAFT) {
            DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){m->vx * TS - 8, m->vy * TS - 8, 32, 32},
                           (Vector2){0, 0}, 0, (Color){120, 220, 255, 60});
        }
    }
    if (rule == R_DISCO) {
        for (i = 0; i < 5; i++) {
            float tm = fx_on ? g_time : 0;   /* reduced motion: still, fixed colours */
            float bx = PV.x * TS + 8 + cosf(tm * 0.9f + i * 1.7f) * 80;
            float by = PV.y * TS + 8 + sinf(tm * 1.3f + i * 2.3f) * 48;
            Color dc = ColorFromHSV(fmodf(tm * 120 + i * 72, 360), 0.8f, 1);
            dc.a = 110;
            DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){bx - 56, by - 56, 112, 112}, (Vector2){0, 0}, 0, dc);
        }
    }
    for (i = 0; i < npart; i++) {
        Part *p = &parts[i];
        float a = p->life / p->max;
        if (p->add != 1) continue;
        DrawRectangle((int)p->x, (int)p->y, (int)fmaxf(1, p->size), (int)fmaxf(1, p->size), alphac(p->c, a));
    }
    EndBlendMode();

    for (i = 0; i < npart; i++) {
        Part *p = &parts[i];
        float a = p->life / p->max;
        if (p->add == 1) continue;
        if (p->add == 2) {   /* a little z */
            Color zc = alphac(p->c, a);
            DrawLine((int)p->x, (int)p->y, (int)p->x + 3, (int)p->y, zc);
            DrawLine((int)p->x + 3, (int)p->y, (int)p->x, (int)p->y + 3, zc);
            DrawLine((int)p->x, (int)p->y + 3, (int)p->x + 3, (int)p->y + 3, zc);
            continue;
        }
        DrawRectangle((int)p->x, (int)p->y, (int)fmaxf(1, p->size), (int)fmaxf(1, p->size), alphac(p->c, a));
    }
    draw_soup_waves();
    for (i = 0; i < nring; i++) {
        float p = rings[i].t / 0.7f, r = 6 + p * 70;
        DrawRing((Vector2){rings[i].x, rings[i].y}, r - 2, r, 0, 360, 48, alphac(rings[i].c, 1 - p));
    }

    EndMode2D();
    EndTextureMode();
}

/* ---------------------------------------------------------- HUD ------- */

/* one ingredient in a slot, with its stack count in the corner */
static void ing_slot(float x, float y, float sz, int g, int count, int lit) {
    float u = uis;
    DrawRectangleRounded((Rectangle){x, y, sz, sz}, 0.22f, 4, lit ? (Color){110, 78, 20, 255} : (Color){60, 44, 50, 255});
    draw_spr_ui(SP_ING + g, x + (sz - 32 * u) / 2, y + (sz - 32 * u) / 2, 2 * u, WHITE, WHITE);
    if (count > 1) {
        char b[8];
        float bw = 26 * u, bh = 17 * u;
        snprintf(b, sizeof b, "x%d", count);
        DrawRectangleRounded((Rectangle){x + sz - bw + 3 * u, y + sz - bh + 3 * u, bw, bh}, 0.4f, 4, (Color){10, 6, 12, 240});
        text_c(b, x + sz - bw / 2 + 3 * u, y + sz - bh + 3 * u, 10, (Color){255, 220, 90, 255});
    }
}

static void draw_float_texts(void) {
    int i;
    for (i = 0; i < nftxt; i++) {
        FText *f = &ftxt[i];
        float p = f->t / f->max, rise = (1 - (1 - p) * (1 - p)) * (f->big ? 20 : 14);
        int size = fs(f->big ? 30 : 20);
        Vector2 s = world_to_screen(f->x, f->y - rise);
        float pop = f->t < 0.1f ? 1.0f + (0.1f - f->t) * 4 : 1.0f;
        int sz = (int)(size * pop / 10) * 10;
        if (sz < 10) sz = 10;
        text_c(f->s, s.x, s.y - sz, sz, alphac(f->c, p > 0.7f ? (1 - p) / 0.3f : 1));
    }
}

/* route stairs: signposts naming the rule of the floor below. Drawn on screen, after the bloom, so they stay
   crisp and readable on a phone. */
static void draw_stair_signs(void) {
    float u = uis;
    int i, f = fs(20);
    for (i = 0; i < nstairs; i++) {
        const char *nm = RULE_NAME[stair_rule[i]];
        Color rc = RULE_COL[stair_rule[i]], dk = {34, 24, 38, 255};
        Color fill = {(unsigned char)(rc.r * 0.3f + 16), (unsigned char)(rc.g * 0.3f + 12), (unsigned char)(rc.b * 0.3f + 16), 255};
        Vector2 top = world_to_screen(tcx(stair_x[i]), (float)(stair_y[i] * TS));
        float bw = text_w(nm, f) + 18 * u, bh = f + 12 * u, bx, by, ps = (float)pscale;
        if (!vis[stair_y[i]][stair_x[i]]) continue;
        bx = floorf(top.x - bw / 2);
        by = floorf(top.y - 8 * ps - bh);
        DrawRectangle((int)(top.x - 3 * u), (int)(by + bh), (int)(6 * u), (int)(top.y - by - bh + 2 * ps), (Color){98, 60, 46, 255});
        DrawRectangle((int)(top.x - 3 * u - 2), (int)(by + bh), 2, (int)(top.y - by - bh + 2 * ps), dk);
        DrawRectangle((int)(top.x + 3 * u), (int)(by + bh), 2, (int)(top.y - by - bh + 2 * ps), dk);
        DrawRectangleRounded((Rectangle){bx - 3, by - 3, bw + 6, bh + 6}, 0.2f, 4, dk);
        DrawRectangleRounded((Rectangle){bx, by, bw, bh}, 0.16f, 4, fill);
        DrawRectangle((int)bx + 4, (int)by, (int)bw - 8, (int)(4 * u), rc);
        DrawRectangle((int)(bx + 5 * u), (int)(by + bh / 2 + 2 * u), 3, 3, rc);
        DrawRectangle((int)(bx + bw - 5 * u - 3), (int)(by + bh / 2 + 2 * u), 3, 3, rc);
        text_c(nm, bx + bw / 2, by + 7 * u, f, (Color){255, 250, 238, 255});
    }
}

/* ---- slim HUD: thin translucent strips pushed to the screen edges so the world is the star ---- */

/* pixel multiple for HUD text and icons: 1 up to ~1080p tall screens, then 2, 3. Text size args are 10 * multiple. */
static int hud_mul(void) {
    /* 1x monogram is too small to read, so 2x from a ~500px-tall canvas; 3x only on very large screens */
    return uis >= 1.7f ? 3 : uis >= 0.7f ? 2 : 1;
}

static void hud_panel(float x, float y, float w, float h, float a) {
    float b = (float)hud_mul();
    DrawRectangle((int)x, (int)y, (int)w, (int)h, (Color){18, 10, 22, (unsigned char)(150 * a)});
    DrawRectangleLinesEx((Rectangle){x, y, w, h}, b, (Color){132, 96, 72, (unsigned char)(170 * a)});
}

/* a sprite scaled by a whole number to fit a box, centred in it */
static void hud_icon(int id, float x, float y, float box, Color tint) {
    float mx = (float)(spr[id].w > spr[id].h ? spr[id].w : spr[id].h);
    float sc = floorf(box / mx);
    if (sc < 1) sc = 1;
    draw_spr_ui(id, x + (box - spr[id].w * sc) / 2, y + (box - spr[id].h * sc) / 2, sc, tint, WHITE);
}

static void slim_bar(float x, float y, float w, float h, float frac, Color c) {
    frac = clampf(frac, 0, 1);
    DrawRectangle((int)x, (int)y, (int)w, (int)h, (Color){10, 6, 12, 220});
    DrawRectangle((int)x, (int)y, (int)(w * frac), (int)h, c);
    DrawRectangle((int)x, (int)y, (int)(w * frac), (int)(h / 3), alphac(WHITE, 0.25f));
    DrawRectangleLinesEx((Rectangle){x, y, w, h}, 1, (Color){70, 50, 58, 255});
}

/* the full recipe card; draw = 0 only measures it */
static void recipe_card(float x, float y, float *ow, float *oh, int draw) {
    float u = uis, rw = 0, row = 40 * u, rx, ry, w, h;
    int g, c, k = 0, nl = 0, f1 = fs(20);
    for (g = 0; g < G_N; g++) {
        if (!P.ing[g]) continue;
        nl++;
        rw = fmaxf(rw, text_w(ING_NAME[g], f1) + 20 + text_w(ING_SHORT[g], f1));
    }
    for (c = 0; c < C_N; c++)
        if (combo_on(c) || P.ing[COMBO[c].a] || P.ing[COMBO[c].b]) {
            nl++;
            rw = fmaxf(rw, text_w(combo_on(c) ? COMBO[c].name : "a secret combo?", f1) + 120 * u);
        }
    w = fmaxf(300 * u, 78 * u + rw + 12 * u);
    h = (44 + (nl ? nl : 1) * 40) * u;
    *ow = w;
    *oh = h;
    if (!draw) return;
    panel(x, y, w, h);
    DrawRectangleRounded((Rectangle){x + 8 * u, y + 8 * u, w - 16 * u, 26 * u}, 0.3f, 4, (Color){70, 40, 34, 255});
    text("SOUP RECIPE", x + 16 * u, y + 11 * u, f1, (Color){252, 230, 190, 255});
    ustar(x + w - 24 * u, y + 21 * u, 7 * u, (Color){255, 214, 70, 255});
    rx = x + 12 * u;
    for (g = 0; g < G_N; g++) {
        int lit = 0;
        if (!P.ing[g]) continue;
        for (c = 0; c < C_N; c++) lit |= combo_on(c) && (COMBO[c].a == g || COMBO[c].b == g);
        ry = y + 40 * u + k++ * row;
        ing_slot(rx, ry, 34 * u, g, P.ing[g], lit);
        text(ING_NAME[g], rx + 46 * u, ry + 7 * u, f1, (Color){246, 238, 224, 255});
        text(ING_SHORT[g], rx + 46 * u + text_w(ING_NAME[g], f1) + 20 * u, ry + 7 * u, f1, (Color){190, 172, 156, 255});
    }
    for (c = 0; c < C_N; c++) {
        int on = combo_on(c);
        float pulse = fx_on && on ? 0.5f + 0.5f * sinf(g_time * 3) : 0.5f;
        if (!on && !P.ing[COMBO[c].a] && !P.ing[COMBO[c].b]) continue;
        ry = y + 40 * u + k++ * row;
        if (on) {
            DrawRectangleRounded((Rectangle){x + 6 * u, ry - 1 * u, w - 12 * u, 38 * u}, 0.25f, 4, (Color){96, 66, 14, 255});
            DrawRectangleRoundedLinesEx((Rectangle){x + 6 * u, ry - 1 * u, w - 12 * u, 38 * u}, 0.25f, 4, 2 * u,
                                        alphac((Color){255, 214, 70, 255}, 0.55f + 0.45f * pulse));
        }
        draw_spr_ui(SP_ING + COMBO[c].a, x + 12 * u, ry + 4 * u, 1 * u, on ? WHITE : (Color){120, 110, 120, 150}, WHITE);
        text("+", x + 30 * u, ry + 7 * u, 20, on ? (Color){255, 220, 90, 255} : (Color){120, 110, 110, 255});
        draw_spr_ui(SP_ING + COMBO[c].b, x + 44 * u, ry + 4 * u, 1 * u, on ? WHITE : (Color){120, 110, 120, 150}, WHITE);
        if (on) ustar(x + 76 * u, ry + 18 * u, 7 * u, (Color){255, 214, 70, 255});
        text(on ? COMBO[c].name : "a secret combo?", x + 92 * u, ry + 7 * u, f1, on ? (Color){255, 226, 110, 255} : (Color){130, 120, 120, 255});
    }
    if (!k) text("Plain soup. Nothing in it yet.", x + 16 * u, y + 46 * u, f1, (Color){130, 120, 120, 255});
}

/* the recipe as a row of ingredient icons with counts; lit while part of a named combo. Hover for the card. */
static void recipe_compact(float x, float y, float *ow, float *oh, int draw) {
    int m = hud_mul(), g, c, n = 0, s1 = 10 * m;
    float pad = 6.0f * m, slot = 36.0f * m, lw = text_w("RECIPE", s1), cx, w, h = slot + 2 * pad;
    for (g = 0; g < G_N; g++) n += P.ing[g] != 0;
    w = pad + lw + pad + (n ? n * (slot + 3.0f * m) : text_w("plain soup", s1) + 3.0f * m) + pad;
    for (c = 0; c < C_N; c++) w += combo_on(c) ? 16.0f * m : 0;
    *ow = w;
    *oh = h;
    if (!draw) return;
    hud_panel(x, y, w, h, 1);
    text("RECIPE", x + pad, y + (h - 12.0f * m) / 2, s1, (Color){230, 210, 190, 255});
    cx = x + pad + lw + pad;
    if (!n) text("plain soup", cx, y + (h - 12.0f * m) / 2, s1, (Color){130, 120, 120, 255});
    for (g = 0; g < G_N; g++) {
        int lit = 0;
        float pulse = fx_on ? 0.5f + 0.5f * sinf(g_time * 3) : 0.5f;
        if (!P.ing[g]) continue;
        for (c = 0; c < C_N; c++) lit |= combo_on(c) && (COMBO[c].a == g || COMBO[c].b == g);
        DrawRectangle((int)cx, (int)(y + pad), (int)slot, (int)slot, lit ? (Color){110, 78, 20, 220} : (Color){60, 44, 50, 220});
        if (lit) DrawRectangleLinesEx((Rectangle){cx, y + pad, slot, slot}, (float)m, alphac((Color){255, 214, 70, 255}, 0.5f + 0.5f * pulse));
        draw_spr_ui(SP_ING + g, cx + (slot - 32.0f * m) / 2, y + pad + (slot - 32.0f * m) / 2, 2.0f * m, WHITE, WHITE);
        if (P.ing[g] > 1) {
            char b[8];
            snprintf(b, sizeof b, "%d", P.ing[g]);
            text(b, cx + slot - text_w(b, s1) - 1.0f * m, y + pad + slot - 11.0f * m, s1, (Color){255, 220, 90, 255});
        }
        cx += slot + 3.0f * m;
    }
    for (c = 0; c < C_N; c++) {
        float pulse = fx_on ? 0.5f + 0.5f * sinf(g_time * 3) : 0.5f;
        if (!combo_on(c)) continue;
        ustar(cx + 6.0f * m, y + h / 2, (6.0f + 2.0f * pulse) * m, (Color){255, 214, 70, 255});
        cx += 16.0f * m;
    }
}

/* the band badge: a pill with the band's icon (flame, steam, goo, snowflake) and its name.
   Returns its width; draw = 0 only measures. A band change pulses it (not in reduced motion). */
static const char *BAND_ICON[B_N][8] = {
    {"...#....", "..##..#.", ".###.##.", ".#o####.", "##oo####", "#ooooo##", ".#oooo#.", "..####.."},
    {".#..#...", "#..#..#.", "#..#..#.", ".#..#.#.", "..#..#..", ".#..#...", "........", "########"},
    {"...##...", "...##...", "..####..", ".##oo##.", ".#oo###.", ".######.", "..####..", "........"},
    {"...##...", "#..##..#", ".#.##.#.", "..####..", "########", "..####..", ".#.##.#.", "#..##..#"}};
static float band_badge(int band, float x, float y, int m, int size, int draw) {
    float cell = (float)(m < 1 ? 1 : m), ih = 8 * cell, hgt = 14.0f * m, bp = 4.0f * m, w;
    float pw = band_pulse * (fx_on ? 1 : 0);
    Color c = BAND_COL[band];
    int rx, ry;
    w = bp + ih + 4.0f * m + text_w(BAND_NAME[band], size) + bp;
    if (!draw) return w;
    DrawRectangleRounded((Rectangle){x, y, w, hgt}, 0.5f, 6, mixc((Color){24, 14, 26, 255}, c, 0.28f));
    DrawRectangleRoundedLinesEx((Rectangle){x, y, w, hgt}, 0.5f, 6, fmaxf(1.0f, (float)m), alphac(c, 0.85f));
    if (pw > 0.01f) {
        float g = (1 - pw) * 5.0f * m;
        DrawRectangleRoundedLinesEx((Rectangle){x - g, y - g, w + 2 * g, hgt + 2 * g}, 0.5f, 6, fmaxf(1.0f, (float)m), alphac(c, pw));
    }
    for (ry = 0; ry < 8; ry++)
        for (rx = 0; rx < 8; rx++) {
            char ch = BAND_ICON[band][ry][rx];
            Color ic;
            if (ch == '.') continue;
            ic = ch == 'o' ? mixc(c, WHITE, 0.6f) : c;
            if (band == B_SCALD && ch == '#' && ry > 4) ic = (Color){255, 80, 40, 255};
            DrawRectangle((int)(x + bp + rx * cell), (int)(y + (hgt - ih) / 2 + ry * cell), (int)cell, (int)cell, ic);
        }
    text(BAND_NAME[band], x + bp + ih + 4.0f * m, y + (hgt - 10.0f * m) / 2 + 1.0f * m, size, c);
    return w;
}

/* the bottom of the HUD's top row (floor panel and soup gauge), in screen pixels: the page puts tips under it */
static float hud_top_bottom;
static void draw_hud(void) {
    float W = (float)GetScreenWidth(), H = (float)GetScreenHeight(), u = uis, x, y, w, h;
    int i, m = hud_mul(), s1 = 10 * m, s2 = 10 * m + 10;   /* headings one step above body text */
    float e = floorf(8 * u), pad = 6.0f * m, tl_bottom, stats_top, soup_bottom = 0;
    Vector2 mp = GetMousePosition();
    char b[128], soup_line[64];

    /* top left: floor and rule, then the recipe row (card on hover) */
    snprintf(b, sizeof b, "FLOOR %d/%d", P.floor, FLOORS);
    snprintf(soup_line, sizeof soup_line, "%s soup", SOUP_NAME[soup_kind]);
    w = fmaxf(fmaxf(text_w(b, s2) + 10.0f * m + text_w(RULE_NAME[rule], s1), text_w(RULE_DESC[rule], s1)),
              text_w(soup_line, s1)) + 2 * pad;
    h = 2 * pad + 35.0f * m + 13.0f * m;
    hud_panel(e, e, w, h, 1);
    text(b, e + pad, e + pad, s2, (Color){252, 230, 190, 255});
    text(RULE_NAME[rule], e + pad + text_w(b, s2) + 10.0f * m, e + pad + 8.0f * m, s1, RULE_COL[rule]);
    text(RULE_DESC[rule], e + pad, e + pad + 22.0f * m, s1, (Color){200, 190, 180, 255});
    text(soup_line, e + pad, e + pad + 35.0f * m, s1, (Color){252, 214, 150, 255});
    tl_bottom = e + h;
    {
        static int recipe_open;
        float cw, ch, ew, eh, ry = tl_bottom + 4.0f * m;
        recipe_compact(e, ry, &cw, &ch, 0);
        recipe_card(e, ry, &ew, &eh, 0);
        recipe_open = ui == UI_PLAY && (CheckCollisionPointRec(mp, (Rectangle){e, ry, cw, ch}) ||
                                        (recipe_open && CheckCollisionPointRec(mp, (Rectangle){e, ry, ew, eh})));
        if (recipe_open) recipe_card(e, ry, &ew, &eh, 1);
        else recipe_compact(e, ry, &cw, &ch, 1);
    }

    /* top right: soup gauge */
    {
        Color sc = soup_col(P.soup);
        float bsc = 2.0f * m, bwid = spr[SP_BOWL].w * bsc, bhgt = spr[SP_BOWL].h * bsc, nw, ww;
        snprintf(b, sizeof b, "%d", P.soup);
        int band = soup_band(P.soup);
        nw = text_w(b, s2) + text_w("o", s1) + 2.0f * m;
        if (band_prev != band) { if (band_prev >= 0 && fx_on) band_pulse = 1; band_prev = band; }
        ww = band_badge(band, 0, 0, m, s1, 0) + 6.0f * m + text_w(soup_word(P.soup), s1);
        w = pad + bwid + 8.0f * m + fmaxf(nw, ww) + pad;
        h = pad + fmaxf(bhgt, 33.0f * m) + 5.0f * m + 7.0f * m + pad;
        x = W - e - w;
        y = e;
        hud_panel(x, y, w, h, 1);
        soup_bottom = y + h;
        hud_top_bottom = fmaxf(tl_bottom, soup_bottom);
        draw_spr_ui(SP_BOWL, x + pad, y + pad, bsc, WHITE, sc);
        if (frand() < P.soup / 100.0f * 0.4f)
            upart_add(x + pad + bwid * frange(0.25f, 0.75f), y + pad, frange(-4, 4) * u, frange(-30, -18) * u, frange(0.8f, 1.4f),
                      frange(2, 4) * u, (Color){255, 248, 240, 70});
        text(b, x + pad + bwid + 8.0f * m, y + pad - 1.0f * m, s2, sc);
        text("o", x + pad + bwid + 8.0f * m + text_w(b, s2) + 1.0f * m, y + pad - 2.0f * m, s1, sc);
        {
            float bwid2 = band_badge(band, x + pad + bwid + 8.0f * m, y + pad + 18.0f * m, m, s1, 1);
            text(soup_word(P.soup), x + pad + bwid + 8.0f * m + bwid2 + 6.0f * m, y + pad + 20.0f * m, s1, sc);
        }
        slim_bar(x + pad, y + h - pad - 7.0f * m, w - 2 * pad, 7.0f * m, P.soup / 100.0f, sc);
    }

    /* fling aim prompt and status chips, top centre */
    y = e;
    if (aim) {
        snprintf(b, sizeof b, "FLING: PICK A DIRECTION");
        w = text_w(b, s2) + 2 * pad;
        hud_panel((W - w) / 2, y, w, 22.0f * m + 2 * pad, 1);
        text_c(b, W / 2, y + pad, s2, (Color){255, 220, 150, 255});
        y += 22.0f * m + 2 * pad + 4.0f * m;
    }
    if (P.confused > 0) {
        snprintf(b, sizeof b, "CONFUSED %d", P.confused);
        w = text_w(b, s2) + 2 * pad;
        hud_panel((W - w) / 2, y, w, 22.0f * m + 2 * pad, 1);
        text_c(b, W / 2, y + pad, s2, (Color){220, 150, 255, 255});
    }

    /* bottom left: HP and XP, with attack, defence and gold */
    {
        float hf = (float)(P.hp < 0 ? 0 : P.hp) / P.maxhp, barw = 84.0f * m, rowh = 20.0f * m, sx;
        Color hc = hf > 0.5f ? (Color){226, 70, 64, 255} : hf > 0.25f ? (Color){240, 150, 60, 255} : (Color){255, 50, 50, 255};
        float pulse = hf <= 0.25f ? 0.5f + 0.5f * sinf(g_time * 10) : 1;
        float xpf = (float)(P.xp - (P.lvl > 1 ? need(P.lvl - 1) : 0)) / (need(P.lvl) - (P.lvl > 1 ? need(P.lvl - 1) : 0));
        float iw = 16.0f * m, lvw = fmaxf(iw, text_w("LV 99", s1));
        char hb[24], ab[16], db[16], gb[16], lv[16], xb[24];
        snprintf(hb, sizeof hb, "%d/%d", P.hp < 0 ? 0 : P.hp, P.maxhp);
        snprintf(ab, sizeof ab, "%d", P.atk);
        snprintf(db, sizeof db, "%d", P.def);
        snprintf(gb, sizeof gb, "%d", P.gold);
        snprintf(lv, sizeof lv, "LV %d", P.lvl);
        snprintf(xb, sizeof xb, "%d xp", P.xp);
        w = pad + lvw + 6.0f * m + barw + 6.0f * m + fmaxf(text_w(hb, s2), text_w(xb, s1)) + 12.0f * m +
            3 * (iw + 4.0f * m + 16.0f * m) + text_w(ab, s2) + text_w(db, s2) + text_w(gb, s2) + pad;
        h = 2 * pad + rowh + 4.0f * m + 14.0f * m;
        x = e;
        y = H - e - h;
        stats_top = y;
        hud_panel(x, y, w, h, 1);
        hud_icon(SP_HEART, x + pad, y + pad + 2.0f * m, iw, alphac(WHITE, 0.6f + 0.4f * pulse));
        slim_bar(x + pad + lvw + 6.0f * m, y + pad + 3.0f * m, barw, 14.0f * m, hf, hc);
        sx = x + pad + lvw + 6.0f * m + barw + 6.0f * m;
        text(hb, sx, y + pad, s2, (Color){250, 230, 220, 255});
        sx += fmaxf(text_w(hb, s2), text_w(xb, s1)) + 12.0f * m;
        hud_icon(SP_WEAPON, sx, y + pad + 2.0f * m, iw, WHITE);
        text(ab, sx + iw + 4.0f * m, y + pad, s2, (Color){255, 190, 120, 255});
        sx += iw + 4.0f * m + text_w(ab, s2) + 16.0f * m;
        hud_icon(SP_ARMOR, sx, y + pad + 2.0f * m, iw, WHITE);
        text(db, sx + iw + 4.0f * m, y + pad, s2, (Color){160, 200, 255, 255});
        sx += iw + 4.0f * m + text_w(db, s2) + 16.0f * m;
        hud_icon(SP_GOLD, sx, y + pad + 2.0f * m, iw, WHITE);
        text(gb, sx + iw + 4.0f * m, y + pad, s2, (Color){252, 212, 78, 255});
        y += pad + rowh + 4.0f * m;
        text(lv, x + pad, y, s1, (Color){252, 222, 110, 255});
        slim_bar(x + pad + lvw + 6.0f * m, y + 3.0f * m, barw, 6.0f * m, xpf, (Color){252, 212, 78, 255});
        text(xb, x + pad + lvw + 6.0f * m + barw + 6.0f * m, y, s1, (Color){200, 190, 160, 255});
    }

    /* right edge, under the soup gauge: pockets as a slim list, names always showing (click a row to use) */
    {
        static unsigned char was_known[2][NPOT];
        static float flash[2][NPOT];
        static int seeded;
        float rh = 18.0f * m, ic = 16.0f * m, kw = 12.0f * m, nwid = text_w("empty. sad.", s1), px, py = soup_bottom + 4.0f * m, pw, ph;
        int j, t;
        if (!seeded) {
            seeded = 1;
            for (j = 0; j < NPOT; j++) was_known[0][j] = (unsigned char)pot_known[j], was_known[1][j] = (unsigned char)scr_known[j];
        }
        for (t = 0; t < 2; t++)
            for (j = 0; j < NPOT; j++) {
                int k = t ? scr_known[j] : pot_known[j];
                if (k && !was_known[t][j] && fx_on) flash[t][j] = 1;
                was_known[t][j] = (unsigned char)k;
                if (flash[t][j] > 0) flash[t][j] = fmaxf(0, flash[t][j] - GetFrameTime() * 1.2f);
            }
        for (i = 0; i < ninv; i++) {
            char n[32];
            pocket_name(inv[i], n, sizeof n, 0);
            nwid = fmaxf(nwid, text_w(n, s1));
        }
        pw = pad + kw + 4.0f * m + ic + 5.0f * m + nwid + pad;
        ph = 2 * pad + (ninv ? ninv : 1) * rh;
        px = W - e - pw;
        hud_panel(px, py, pw, ph, 1);
        if (!ninv) text("empty. sad.", px + pad, py + pad + 3.0f * m, s1, (Color){120, 110, 110, 255});
        for (i = 0; i < ninv; i++) {
            float ry = py + pad + i * rh, fl = 0;
            Rectangle r = {px + 2.0f * m, ry, pw - 4.0f * m, rh};
            int on = ui == UI_PLAY && hot_add(r, KEY_ONE + i), sp, known = 1;
            char n[32], kb[4];
            if (inv[i].k == I_POTION) known = pot_known[inv[i].sub], fl = flash[0][inv[i].sub];
            else if (inv[i].k == I_SCROLL) known = scr_known[inv[i].sub], fl = flash[1][inv[i].sub];
            if (on) DrawRectangle((int)r.x, (int)r.y, (int)r.width, (int)r.height, (Color){255, 220, 140, 46});
            if (fl > 0) DrawRectangle((int)r.x, (int)r.y, (int)r.width, (int)r.height, alphac((Color){255, 240, 190, 255}, 0.5f * fl));
            snprintf(kb, sizeof kb, "%d", i + 1);
            text(kb, px + pad, ry + 3.0f * m, s1, (Color){252, 230, 190, 255});
            sp = inv[i].k == I_POTION ? SP_POTION : inv[i].k == I_INGREDIENT ? SP_ING + inv[i].sub : inv[i].k == I_ROCK ? SP_ROCK : SP_SCROLL;
            {
                float sc = floorf(ic / (float)(spr[sp].w > spr[sp].h ? spr[sp].w : spr[sp].h));
                if (sc < 1) sc = 1;
                draw_spr_ui(sp, px + pad + kw + 4.0f * m + (ic - spr[sp].w * sc) / 2, ry + (rh - spr[sp].h * sc) / 2, sc, WHITE,
                            inv[i].k == I_POTION ? LOOK_COL[pot_look[inv[i].sub]] : WHITE);
            }
            pocket_name(inv[i], n, sizeof n, 0);
            text(n, px + pad + kw + 4.0f * m + ic + 5.0f * m, ry + 3.0f * m, s1,
                 known ? (Color){240, 232, 220, 255} : (Color){190, 170, 210, 255});
        }
    }

    /* bottom left, above the stats: the log, a few fading lines; the whole backlog on hover */
    {
        static int log_open;
        float lh = 15.0f * m, lw = W - 2 * e;
        int base = 3, lines = log_open ? 8 : base, k, shown = 0;
        float ly1 = stats_top - 6.0f * m;
        Rectangle hr = {e, ly1 - lines * lh - pad, lw, lines * lh + 2 * pad};
        Rectangle hb0 = {e, ly1 - base * lh - pad, lw, base * lh + 2 * pad};
        log_open = ui == UI_PLAY && nlog > 0 && (CheckCollisionPointRec(mp, hb0) || (log_open && CheckCollisionPointRec(mp, hr)));
        lines = log_open ? 8 : base;
        for (k = 0; k < lines; k++) {
            int idx = nlog - 1 - k;
            float age, a;
            if (idx < 0) break;
            age = g_time - logt[idx];
            a = log_open ? 0.55f + 0.45f * (k == 0) : age < 5 ? 1.0f : 1.0f - (age - 5) / 3.0f;
            if (!log_open) a *= 1.0f - 0.22f * k;
            if (a <= 0.02f) break;
            shown++;
            BeginScissorMode((int)e, (int)(ly1 - (k + 1) * lh - 2), (int)lw, (int)lh + 4);
            text(logm[idx], e + pad, ly1 - (k + 1) * lh, s1,
                 alphac(age < 1.5f ? (Color){255, 248, 230, 255} : (Color){214, 204, 192, 255}, a));
            EndScissorMode();
        }
        if (log_open && shown) DrawRectangle((int)e, (int)(ly1 - shown * lh - pad), (int)lw, (int)(shown * lh + pad), (Color){18, 10, 22, 70});
    }

    /* mouse tooltip: the HUD talks */
    {
        Vector2 mp = GetMousePosition();
        int tx = (int)floorf((mp.x / pscale + cam_left) / TS), ty = (int)floorf((mp.y / pscale + cam_top) / TS);
        const char *l1 = NULL;
        char l2[96] = "";
        if (inb(tx, ty) && ui == UI_PLAY) {
            Mon *m = mon_at(tx, ty);
            Item *it = item_at(tx, ty);
            if (tx == P.x && ty == P.y) {
                l1 = "You";
                snprintf(l2, sizeof l2, "Holding %s soup.", soup_word(P.soup));
            } else if (m && vis[ty][tx] && m->revealed) {
                l1 = MT[m->t].name;
                snprintf(l2, sizeof l2, "%s  HP %d/%d%s%s%s", MT[m->t].blurb, m->hp, m->maxhp, m->sleep ? "  (asleep)" : "",
                         m->burn ? "  (burning)" : "", m->stuck ? "  (stuck)" : "");
            } else if (m && vis[ty][tx]) {
                l1 = "A potion";
                snprintf(l2, sizeof l2, "Looks %s. Looks innocent.", pot_name[m->look]);
            } else if (it && seen[ty][tx]) {
                static const char *IN[] = {"Gold", "", "", "Warm rock", "Weapon", "Armor", "Ingredient"};
                Pocket p;
                char n[48];
                p.k = it->k;
                p.sub = it->sub;
                if (it->k == I_POTION || it->k == I_SCROLL) {
                    pocket_name(p, n, sizeof n, 1);
                    snprintf(l2, sizeof l2, "%s", n);
                    l1 = it->k == I_POTION ? "Potion" : "Scroll";
                } else {
                    l1 = IN[it->k];
                    snprintf(l2, sizeof l2, "%s",
                             it->k == I_GOLD ? "Shiny. Probably real." : it->k == I_ROCK ? "Pocket it, then drop it in your soup. +20 warmth."
                             : it->k == I_INGREDIENT ? ING_DESC[it->sub]
                             : it->k == I_WEAPON ? WEAPONS[it->sub] : ARMORS[it->sub]);
                }
            } else if (seen[ty][tx] && tile[ty][tx] == '>') {
                l1 = "Stairs down";
                snprintf(l2, sizeof l2, "Stand on them. Press E.");
            } else if (seen[ty][tx] && tile[ty][tx] == 'V') {
                l1 = "Vending machine";
                snprintf(l2, sizeof l2, "Walk into it. %d gold. Usually works.", P.perk[PK_BARGAIN] ? 5 : 10);
            }
        }
        if (l1) {
            char cap[64];
            float tw, tx0, ty0;
            snprintf(cap, sizeof cap, "%s", l1);
            cap[0] = (char)toupper((unsigned char)cap[0]);
            l1 = cap;
            tw = fmaxf(text_w(l1, s2), text_w(l2, s1)) + 2 * pad;
            tx0 = mp.x + 18 * u;
            ty0 = mp.y + 10 * u;
            if (tx0 + tw > W) tx0 = mp.x - tw - 10 * u;
            hud_panel(tx0, ty0, tw, 2 * pad + 22.0f * m + 13.0f * m, 1.5f);
            text(l1, tx0 + pad, ty0 + pad, s2, (Color){252, 222, 150, 255});
            text(l2, tx0 + pad, ty0 + pad + 22.0f * m, s1, (Color){220, 210, 200, 255});
        }
    }

    /* floor banner */
    if (banner_t < 3.2f) {
        float a = banner_t < 0.4f ? banner_t / 0.4f : banner_t > 2.4f ? (3.2f - banner_t) / 0.8f : 1;
        float cy = H * 0.3f, y2, y3, y4;
        int ft = fs(50), f1 = fs(20), f2 = fs(30);
        snprintf(b, sizeof b, "FLOOR %d", P.floor);
        y2 = cy + 11.0f * font_mul(ft) + 8 * u;
        y3 = y2 + 11.0f * font_mul(f2) + 8 * u;
        y4 = y3 + 11.0f * font_mul(f1) + 14 * u;
        DrawRectangle(0, (int)(cy - 20 * u), (int)W, (int)(y4 - cy + 20 * u), alphac((Color){8, 4, 12, 255}, a * 0.6f));
        text_c(b, W / 2, cy, ft, alphac((Color){252, 236, 200, 255}, a));
        text_c(RULE_NAME[rule], W / 2, y2, f2, alphac(RULE_COL[rule], a));
        text_c(RULE_DESC[rule], W / 2, y3, f1, alphac((Color){210, 200, 190, 255}, a));
    }
}

static void wrap_text(const char *s, float x, float y, float maxw, int size, Color c, float lh);

/* how many lines wrap_text would use */
static int wrap_lines(const char *s, float maxw, int size) {
    char line[300] = "", trial[300];
    const char *p = s;
    int n = 1;
    while (*p) {
        size_t k = 0;
        char word[64];
        while (*p == ' ') p++;
        while (*p && *p != ' ' && k < sizeof word - 1) word[k++] = *p++;
        word[k] = 0;
        if (!k) break;
        snprintf(trial, sizeof trial, "%s%s%s", line, line[0] ? " " : "", word);
        if (line[0] && text_w(trial, size) > maxw) { n++; snprintf(line, sizeof line, "%s", word); }
        else snprintf(line, sizeof line, "%s", trial);
    }
    return n;
}

/* the duck's phase banner: hazard tape, duck mascots and a honk. Also drawn over the compact layout. */
static void draw_phase_banner(void) {
    float W = (float)GetScreenWidth(), H = (float)GetScreenHeight(), u = uis, cy, bh, a, ease, ms, sw;
    int ph = phase_banner, big = 50, f1 = fs(20), i, nl;
    const char *honk = ph == 3 ? "SLURP!" : "HONK!";
    Color yl = {255, 214, 70, 255}, dk = {34, 24, 38, 255};
    if (phase_t >= 3.2f || ph < 2) return;
    a = phase_t < 0.4f ? phase_t / 0.4f : phase_t > 2.4f ? (3.2f - phase_t) / 0.8f : 1;
    ease = fx_on ? 1 - powf(1 - clampf(phase_t / 0.45f, 0, 1), 3) : 1;
    cy = H * 0.3f;
    ms = 5.0f * u;
    sw = W * 0.9f;
    if (W > 860 * u) sw = W - 2 * (16 * ms + 40 * u);   /* the mascots take the ends */
    while (big > 20 && text_w(PHASE_BIG[ph], fs(big)) > sw) big -= 10;
    nl = wrap_lines(PHASE_SMALL[ph], sw, f1);
    bh = (11.0f * font_mul(fs(big)) + 11.0f * font_mul(f1) + (nl > 1 ? (nl - 1) * 14.0f * font_mul(f1) : 0)) + 50 * u;
    DrawRectangle(0, (int)(cy - 16 * u), (int)W, (int)bh, alphac((Color){44, 22, 56, 255}, a * 0.97f));
    for (i = 0; i < 2; i++) {   /* hazard tape: diagonal yellow and dark stripes */
        float ty = i ? cy - 16 * u + bh - 10 * u : cy - 16 * u, sx;
        DrawRectangle(0, (int)ty, (int)W, (int)(10 * u), alphac(yl, a));
        for (sx = -10 * u; sx < W; sx += 28 * u) {
            tri((Vector2){sx, ty + 10 * u}, (Vector2){sx + 14 * u, ty + 10 * u}, (Vector2){sx + 24 * u, ty}, alphac(dk, a));
            tri((Vector2){sx + 10 * u, ty}, (Vector2){sx + 24 * u, ty}, (Vector2){sx + 14 * u, ty + 10 * u}, alphac(dk, a));
        }
    }
    if (W > 860 * u) {   /* the mascots, facing in */
        float off = (1 - ease) * 220 * u, pop = fx_on ? sinf(g_time * 6) * 2 * u : 0, top = cy - 16 * u + 18 * u;
        float lx = 28 * u - off, rx = W - 28 * u - 16 * ms + off, bw, bx, by;
        int sid = ph == 3 ? SP_DUCK : SP_DUCKLING;
        draw_spr_ui_flip(sid, lx, top + pop, ms, alphac(WHITE, a), 1);
        if (ph == 2) draw_spr_ui_flip(SP_DUCKLING, lx + 16 * ms * 0.7f, top + 28 * u - pop, ms * 0.7f, alphac(WHITE, a), 1);
        draw_spr_ui_flip(sid, rx, top - pop, ms, alphac(WHITE, a), 0);
        bw = text_w(honk, f1) + 24 * u;
        bx = rx + 16 * ms * 0.15f - bw * 0.5f;
        by = cy - 16 * u - 40 * u;
        DrawRectangleRounded((Rectangle){bx - 2 * u, by - 2 * u, bw + 4 * u, 34 * u}, 0.4f, 4, alphac(dk, a));
        DrawRectangleRounded((Rectangle){bx, by, bw, 30 * u}, 0.4f, 4, alphac(WHITE, a));
        tri((Vector2){bx + bw * 0.5f - 10 * u, by + 28 * u}, (Vector2){bx + bw * 0.5f + 8 * u, by + 28 * u}, (Vector2){bx + bw * 0.5f + 12 * u, by + 46 * u}, alphac(dk, a));
        tri((Vector2){bx + bw * 0.5f - 7 * u, by + 28 * u}, (Vector2){bx + bw * 0.5f + 6 * u, by + 28 * u}, (Vector2){bx + bw * 0.5f + 11 * u, by + 42 * u}, alphac(WHITE, a));
        text_cc(honk, bx + bw / 2, by + 15 * u, f1, alphac(dk, a));
    }
    {   /* the title, with a thick edge so it reads on any floor */
        int dx, dy, th = fs(big);
        for (dx = -2; dx <= 2; dx += 2)
            for (dy = -2; dy <= 2; dy += 2)
                if (dx || dy) text_c(PHASE_BIG[ph], W / 2 + dx * u, cy + 6 * u + dy * u, th, alphac(dk, a));
        text_c(PHASE_BIG[ph], W / 2, cy + 6 * u, th, alphac(yl, a));
    }
    if (nl <= 1) text_c(PHASE_SMALL[ph], W / 2, cy + 6 * u + 11.0f * font_mul(fs(big)) + 10 * u, f1, alphac((Color){255, 246, 224, 255}, a));
    else wrap_text(PHASE_SMALL[ph], (W - sw) / 2, cy + 6 * u + 11.0f * font_mul(fs(big)) + 8 * u, sw, f1, alphac((Color){255, 246, 224, 255}, a), 14.0f * font_mul(f1) + 3 * u);
}

/* UI particles (steam over the HUD bowl and title) */
static void update_draw_uparts(float dt) {
    int i;
    for (i = 0; i < nupart;) {
        Part *p = &uparts[i];
        float a;
        p->life -= dt;
        if (p->life <= 0) { uparts[i] = uparts[--nupart]; continue; }
        p->x += p->vx * dt + sinf(p->life * 5 + i) * 10 * dt * uis;
        p->y += p->vy * dt;
        a = p->life / p->max;
        {
            float r = p->size * (2.6f - a * 1.2f);
            DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){p->x - r, p->y - r, r * 2, r * 2},
                           (Vector2){0, 0}, 0, alphac(p->c, a * (a < 0.85f ? 1 : (1 - a) / 0.15f)));
        }
        i++;
    }
}

/* ------------------------------------------------------- modals ------ */

static void dim(float a) {
    DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), alphac((Color){6, 2, 10, 255}, a));
}

static void wrap_text(const char *s, float x, float y, float maxw, int size, Color c, float lh) {
    char line[300] = "", trial[300], word[64];
    const char *p = s;
    while (*p) {
        size_t n = 0, ll;
        while (*p == ' ') p++;
        while (*p && *p != ' ' && n < sizeof word - 1) word[n++] = *p++;
        word[n] = 0;
        if (!n) break;
        ll = strlen(line);
        if (ll + n + 2 >= sizeof trial) break;
        strcpy(trial, line);
        if (ll) strcat(trial, " ");
        strcat(trial, word);
        if (ll && text_w(trial, size) > maxw) {
            text(line, x, y, size, c);
            y += lh;
            strcpy(line, word);
        } else {
            strcpy(line, trial);
        }
    }
    if (line[0]) text(line, x, y, size, c);
}

static void draw_perk_modal(void) {
    float W = (float)GetScreenWidth(), H = (float)GetScreenHeight(), u = uis;
    float cw = 300 * u, gap = 28 * u, total = 3 * cw + 2 * gap, x0 = (W - total) / 2, y0 = H * 0.38f;
    float lh = fmaxf(28 * u, 12.0f * font_mul(fs(20)) + 2), ch, dy = 130 * u;
    int i, nf = fs(30), maxl = 1, anyhave = 0;
    Vector2 mp = GetMousePosition();
    /* every card is as tall as the tallest wrapped description, and every name shares the one size at which all fit */
    for (i = 0; i < nperk_opts; i++) {
        int l = wrap_lines(PERK_DESC[perk_opts[i]], cw - 44 * u, fs(20));
        if (l > maxl) maxl = l;
        if (P.perk[perk_opts[i]]) anyhave = 1;
        while (nf > 10 && text_w(PERK_NAME[perk_opts[i]], nf) > cw - 16 * u) nf -= 10;
    }
    ch = fmaxf(200 * u, dy + maxl * lh + 18 * u + (anyhave ? 30 * u : 0));
    dim(0.55f);
    {
        const char *t = "LEVEL UP!";
        int sz = fs(60), k;
        float tw = text_w(t, sz), xx = W / 2 - tw / 2;
        for (k = 0; t[k]; k++) {
            char c1[2] = {t[k], 0};
            float yy = H * 0.14f + sinf(g_time * 5 + k * 0.6f) * 6 * u;
            text(c1, xx, yy, sz, mixc((Color){255, 236, 120, 255}, (Color){255, 150, 70, 255}, k / 8.0f));
            xx += text_w(c1, sz) + sz / 10.0f;
        }
        text_c("Pick a perk. Press 1, 2 or 3, or click.", W / 2, H * 0.14f + 74 * u, fs(20), (Color){220, 210, 200, 255});
    }
    perk_hover = -1;
    for (i = 0; i < nperk_opts; i++) {
        float t = clampf(perk_anim * 3 - i * 0.35f, 0, 1), e = 1 - (1 - t) * (1 - t) * (1 - t);
        float x = x0 + i * (cw + gap), y = y0 + (1 - e) * 80 * u;
        Rectangle r = {x, y, cw, ch};
        int hov = CheckCollisionPointRec(mp, r);
        char b[16];
        if (i < 3) perk_rect[i] = r;
        if (hov) { perk_hover = i; hot_hovered = 1; y -= 8 * u; }
        if (e <= 0) continue;
        panel(x, y, cw, ch);
        if (hov) DrawRectangleRounded((Rectangle){x, y, cw, ch}, 0.1f, 6, (Color){255, 220, 140, 30});
        snprintf(b, sizeof b, "%d", i + 1);
        DrawCircle((int)(x + cw / 2), (int)(y + 40 * u), 26 * u, (Color){252, 212, 78, 255});
        text_c(b, x + cw / 2, y + 26 * u, fs(30), (Color){40, 24, 30, 255});
        text_c(PERK_NAME[perk_opts[i]], x + cw / 2, y + 86 * u, nf, (Color){252, 236, 200, 255});
        wrap_text(PERK_DESC[perk_opts[i]], x + 22 * u, y + dy, cw - 44 * u, fs(20), (Color){220, 210, 200, 255}, lh);
        if (P.perk[perk_opts[i]]) {
            snprintf(b, sizeof b, "have x%d", P.perk[perk_opts[i]]);
            text_c(b, x + cw / 2, y + ch - 36 * u, fs(20), (Color){160, 150, 140, 255});
        }
    }
}

/* a titled box of text lines. ic (optional) puts an icon before a line: -1 none, (count << 8 | g) an
   ingredient slot, -2 - c a combo star. */
static Rectangle box_rect;

static void draw_box_icons(const char *title, const char **L, const int *ic, int n, Color tc) {
    float W = (float)GetScreenWidth(), H = (float)GetScreenHeight(), u = uis, w = 0, h = 100 * u, x, y, rs = 1, tw, rowmin = 15.0f / (28 * u);
    int i, f1 = fs(20), f3 = fs(40), pass, rows[64];
    if (n > 64) n = 64;
    /* the pixel font is chunky: step the text down a size (and tighten the rows) until the box fits the screen.
       A line too long for the screen wraps onto more rows instead of running off the box. */
    for (pass = 0; pass < 6; pass++) {
        if (pass) {
            f1 = f1 > 10 ? f1 - 10 : 10;
            f3 = f3 > 20 ? f3 - 10 : 20;
            rs = fmaxf(rs * 0.78f, rowmin);
        }
        w = 0;
        h = 100 * u * rs;
        tw = W - 16 * u - 60 * u;
        for (i = 0; i < n; i++) {
            int has = ic && ic[i] != -1;
            float lw = tw - (has ? 48 * u : 0);
            rows[i] = L[i][0] ? wrap_lines(L[i], lw, f1) : 1;
            w = fmaxf(w, fminf(text_w(L[i], f1), lw) + (has ? 48 * u : 0));
            h += ((has ? 40 : 28) + (rows[i] - 1) * 28) * u * rs;
        }
        w = fmaxf(w, text_w(title, f3)) + 60 * u;
        if (w <= W - 16 * u && h <= H - 16 * u) break;
    }
    x = (W - w) / 2;
    y = (H - h) / 2;
    box_rect = (Rectangle){x, y, w, h};
    panel(x, y, w, h);
    text_c(title, W / 2, y + 22 * u * rs, f3, tc);
    y += 80 * u * rs;
    for (i = 0; i < n; i++) {
        int has = ic && ic[i] != -1;
        Color c = {230, 220, 210, 255};
        if (has && ic[i] <= -2) {
            ustar(x + 48 * u, y + 15 * u, 8 * u, (Color){255, 214, 70, 255});
            c = (Color){255, 226, 110, 255};
        } else if (has) {
            ing_slot(x + 30 * u, y - 2 * u, 34 * u, ic[i] & 255, ic[i] >> 8, 0);
        }
        if (rows[i] > 1) wrap_text(L[i], x + (has ? 78 : 30) * u, y + (has ? 6 : 0) * u, tw - (has ? 48 * u : 0), f1, c, 28 * u * rs);
        else text(L[i], x + (has ? 78 : 30) * u, y + (has ? 6 : 0) * u, f1, c);
        y += ((has ? 40 : 28) + (rows[i] - 1) * 28) * u * rs;
    }
}

static void draw_box_lines(const char *title, const char **L, int n, Color tc) {
    draw_box_icons(title, L, NULL, n, tc);
}

/* split a name over two lines at the space that balances them best */
static void split2(const char *s, char *l1, char *l2, size_t n) {
    size_t len = strlen(s), best = 0, i;
    l1[0] = l2[0] = 0;
    for (i = 1; i + 1 < len; i++)
        if (s[i] == ' ' && (!best || (i > len / 2 ? i - len / 2 : len / 2 - i) < (best > len / 2 ? best - len / 2 : len / 2 - best))) best = i;
    if (!best || len < 14) { snprintf(l1, n, "%s", s); return; }
    snprintf(l1, n, "%.*s", (int)best, s);
    snprintf(l2, n, "%s", s + best + 1);
}

static void keycap(float x, float y, float sz, const char *label, Color c) {
    pix_btn((Rectangle){x, y, sz, sz + 3 * uis}, c, (Color){20, 12, 24, 255}, 0);
    text_cc(label, x + sz / 2, y + sz / 2 - 1 * uis, fs(10), (Color){34, 24, 38, 255});
}

/* a row of clickable key buttons along the bottom of the last box */
static void box_buttons(const char **label, const char **cap, const int *key, int n) {
    float u = uis, gap = 14 * u, bh = 44 * u, total = -gap, bw[4], x, y = box_rect.y + box_rect.height - 20 * u - bh;
    int i, f1 = fs(20);
    for (i = 0; i < n; i++) total += (bw[i] = text_w(label[i], f1) + fmaxf(28 * u, text_w(cap[i], fs(10)) + 14 * u) + 34 * u) + gap;
    x = box_rect.x + (box_rect.width - total) / 2;
    for (i = 0; i < n; i++) {
        Rectangle r = {x, y, bw[i], bh};
        int on = hot_add(r, key[i]), down = on && IsMouseButtonDown(MOUSE_BUTTON_LEFT);
        float kw = fmaxf(28 * u, text_w(cap[i], fs(10)) + 14 * u), oy = down ? 3 * u : 0;
        pix_btn(r, on ? (Color){138, 94, 66, 255} : (Color){98, 68, 58, 255}, on ? (Color){255, 214, 70, 255} : (Color){20, 12, 24, 255}, down);
        pix_btn((Rectangle){x + 9 * u, y + 8 * u + oy, kw, 31 * u}, (Color){252, 212, 78, 255}, (Color){20, 12, 24, 255}, 0);
        text_cc(cap[i], x + 9 * u + kw / 2, y + 8 * u + oy + 14 * u, fs(10), (Color){34, 24, 38, 255});
        text(label[i], x + kw + 18 * u, y + 9 * u + oy, f1, (Color){240, 232, 220, 255});
        x += bw[i] + gap;
    }
}

/* a vending machine: marquee, glass with three coils, price LCDs and a walk-away flap.
   A machine in a mood (a ??? offer) goes purple and sulks. */
static void draw_vend_modal(void) {
    float W = (float)GetScreenWidth(), H = (float)GetScreenHeight(), u = uis;
    float mw = fminf(780 * u, W - 24 * u), mh = 444 * u, x0 = (W - mw) / 2, y0 = (H - mh) / 2, cw, wx, wy, ww, wh;
    int i, f1 = fs(20), moody = 0, vlf = f1, mof = f1, pf = f1;
    float lcdw = 128 * u;
    Color body, edge, deep, accent, mq;
    for (i = 0; i < 3; i++) moody |= offers[i].moody;
    if (moody) {
        body = (Color){118, 58, 146, 255}; edge = (Color){58, 24, 84, 255}; deep = (Color){32, 14, 46, 255};
        accent = (Color){255, 120, 214, 255}; mq = (Color){255, 150, 226, 255};
    } else {
        body = (Color){52, 72, 156, 255}; edge = (Color){26, 36, 88, 255}; deep = (Color){14, 18, 44, 255};
        accent = (Color){110, 176, 255, 255}; mq = (Color){160, 206, 255, 255};
    }
    dim(0.6f);
    for (i = 0; i < 3; i++) {   /* one label size for all three coils: the largest at which every name fits */
        char n[48], l1[48], l2[48];
        if (offers[i].sold || offers[i].moody) continue;
        pocket_name((Pocket){offers[i].k, offers[i].sub}, n, sizeof n, 1);
        split2(n, l1, l2, sizeof l1);
        while (vlf > 10 && fmaxf(text_w(l1, vlf), text_w(l2, vlf)) > (mw - 40 * u) / 3 - 4 * u) vlf -= 10;
    }
    /* the mood text and the price LCDs also get one size each, chosen so they fit a coil */
    while (mof > 10 && fmaxf(text_w("a mystery", mof), text_w("(it is sulking)", mof)) > (mw - 40 * u) / 3 - 8 * u) mof -= 10;
    for (i = 0; i < 3; i++) {
        char pr[24];
        snprintf(pr, sizeof pr, offers[i].moody ? "%d gold!" : "%d gold", offer_price(&offers[i]));
        while (pf > 10 && text_w(pr, pf) + 16 * u > (mw - 40 * u) / 3 - 8 * u) pf -= 10;
        lcdw = fmaxf(lcdw, text_w(pr, pf) + 24 * u);
    }
    lcdw = fminf(lcdw, (mw - 40 * u) / 3 - 8 * u);
    DrawRectangleRounded((Rectangle){x0 - 5 * u, y0 - 5 * u, mw + 10 * u, mh + 10 * u}, 0.05f, 6, (Color){34, 24, 38, 255});
    DrawRectangleRounded((Rectangle){x0, y0, mw, mh}, 0.045f, 6, body);
    DrawRectangleRounded((Rectangle){x0 + 6 * u, y0 + 6 * u, mw - 12 * u, 6 * u}, 0.5f, 4, alphac(WHITE, 0.14f));
    /* marquee */
    DrawRectangleRounded((Rectangle){x0 + 20 * u, y0 + 18 * u, mw - 40 * u, 62 * u}, 0.2f, 6, deep);
    for (i = 0; i < 18; i++) {
        float bx = x0 + 32 * u + i * ((mw - 64 * u) / 17);
        int on = fx_on ? (i + (int)(g_time * 3)) % 2 : i % 2;
        DrawCircle((int)bx, (int)(y0 + 24 * u), 3 * u, on ? (Color){255, 226, 120, 255} : (Color){110, 90, 60, 255});
        DrawCircle((int)bx, (int)(y0 + 74 * u), 3 * u, on ? (Color){110, 90, 60, 255} : (Color){255, 226, 120, 255});
    }
    text_c("VENDING MACHINE", W / 2, y0 + 28 * u, fs(40), mq);
    /* glass */
    wx = x0 + 20 * u; wy = y0 + 96 * u; ww = mw - 40 * u; wh = 262 * u;
    DrawRectangleRounded((Rectangle){wx - 4 * u, wy - 4 * u, ww + 8 * u, wh + 8 * u}, 0.04f, 6, edge);
    DrawRectangle((int)wx, (int)wy, (int)ww, (int)wh, deep);
    cw = ww / 3;
    for (i = 0; i < 3; i++) {
        const Offer *o = &offers[i];
        float cx = wx + i * cw, mid = cx + cw / 2, ix = mid - 40 * u, iy = wy + 34 * u;
        char n[48], k[2] = {(char)('1' + i), 0}, l1[48], l2[48], pr[24];
        int j;
        if (i) DrawRectangle((int)cx, (int)wy + 4, (int)(2 * u), (int)wh - 8, edge);
        if (hot_add((Rectangle){cx, wy, cw, wh}, KEY_ONE + i) && !o->sold)
            DrawRectangle((int)(cx + 2 * u), (int)wy, (int)(cw - 4 * u), (int)wh, alphac(accent, 0.12f));
        /* the coil the item hangs on */
        for (j = 0; j < 6; j++) DrawRectangle((int)(mid - 36 * u), (int)(wy + 118 * u - j * 5 * u), (int)(72 * u), (int)(2 * u), (Color){60, 64, 96, 255});
        DrawRectangle((int)(cx + 10 * u), (int)(wy + 122 * u), (int)(cw - 20 * u), (int)(4 * u), (Color){90, 94, 130, 255});
        keycap(cx + 10 * u, wy + 8 * u, 28 * u, k, o->sold ? (Color){110, 100, 110, 255} : (Color){255, 214, 70, 255});
        if (o->sold) {
            text_c("SOLD", mid, wy + 70 * u, f1, (Color){255, 100, 90, 255});
            text_c("OUT", mid, wy + 92 * u, f1, (Color){255, 100, 90, 255});
            snprintf(pr, sizeof pr, "--");
        } else if (o->moody) {
            float r = 40 * u + (fx_on ? sinf(g_time * 2.2f) * 4 * u : 0);
            DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){mid - r - 20 * u, wy + 76 * u - r, r * 2 + 40 * u, r * 2},
                           (Vector2){0, 0}, 0, (Color){255, 100, 220, 140});
            text_c("???", mid + (fx_on ? sinf(g_time * 3) * 3 * u : 0), wy + 52 * u, fs(40), (Color){255, 190, 240, 255});
            ustar(mid - 46 * u, wy + 40 * u, 6 * u, (Color){255, 214, 70, 255});
            ustar(mid + 46 * u, wy + 100 * u, 5 * u, (Color){255, 214, 70, 255});
            snprintf(pr, sizeof pr, "%d gold!", offer_price(o));
        } else {
            if (o->k == I_POTION) draw_spr_ui(SP_POTION, ix, iy, 5 * u, WHITE, LOOK_COL[pot_look[o->sub]]);
            else draw_spr_ui(SP_SCROLL, ix, iy, 5 * u, WHITE, WHITE);
            snprintf(pr, sizeof pr, "%d gold", offer_price(o));
        }
        /* label + price LCD */
        if (!o->sold && !o->moody) {
            pocket_name((Pocket){o->k, o->sub}, n, sizeof n, 1);
            split2(n, l1, l2, sizeof l1);
            text_c(l1, mid, wy + 134 * u, vlf, (Color){240, 232, 220, 255});
            if (l2[0]) text_c(l2, mid, wy + 160 * u, vlf, (Color){240, 232, 220, 255});
        } else if (o->moody) {
            text_c("a mystery", mid, wy + 134 * u, mof, (Color){255, 190, 240, 255});
            text_c("(it is sulking)", mid, wy + 156 * u, mof, (Color){200, 150, 200, 255});
        }
        DrawRectangleRounded((Rectangle){mid - lcdw / 2, wy + 208 * u, lcdw, 36 * u}, 0.25f, 4, (Color){6, 20, 12, 255});
        DrawRectangleRoundedLinesEx((Rectangle){mid - lcdw / 2, wy + 208 * u, lcdw, 36 * u}, 0.25f, 4, 2 * u, (Color){60, 96, 72, 255});
        text_c(pr, mid, wy + 215 * u, pf, o->sold ? (Color){70, 100, 80, 255} : o->moody ? (Color){255, 150, 226, 255} : (Color){120, 255, 150, 255});
    }
    DrawRectangle((int)wx, (int)wy, (int)ww, (int)(8 * u), alphac(WHITE, 0.05f));
    /* the flap and the hint */
    {
        float fw = fmaxf(250 * u, 52 * u + text_w("WALK AWAY", f1) + 20 * u), fx = x0 + mw / 2 - fw / 2, fy = wy + wh + 18 * u, oy;
        int on = hot_add((Rectangle){fx, fy, fw, 46 * u}, KEY_FOUR), down = on && IsMouseButtonDown(MOUSE_BUTTON_LEFT);
        oy = down ? 3 * u : 0;
        pix_btn((Rectangle){fx, fy, fw, 46 * u}, on ? mixc(deep, accent, 0.35f) : deep, on ? accent : edge, down);
        keycap(fx + 12 * u, fy + 8 * u + oy, 28 * u, "4", accent);
        text("WALK AWAY", fx + 52 * u, fy + 10 * u + oy, f1, (Color){240, 232, 220, 255});
    }
}

/* hand-drawn 12x12 pixel icons for the eight kitchen upgrades, in K_ order */
static const char *KIT_ICO[K_N][12] = {
    {"..e...e.....", "...e...e....", ".kkkkkkkkkk.", ".kssssssssk.", "kkkkkkkkkkkk", "kGGggggggggk", "kGgggggggggk", "kkggggggggkk",
     ".kggggggggk.", "..kkkkkkkk..", "..kk....kk..", "............"},
    {"............", "............", "..kkkkkk....", ".kCCCCCCk...", ".kCccccckkkk", ".kCcccccbbbk", ".kcccccckkkk", ".kcccccck...",
     "..kkkkkk....", "............", "............", "............"},
    {"..........kk", ".........kGk", "........kGk.", ".......kGk..", "..kkkkkGk...", ".kssssskk...", "kGsssssssGk.", "kGGsssssGGk.",
     ".kGGGGGGGk..", "..kkkkkkk...", "............", "............"},
    {"..........kk", ".........kGk", "........kGk.", ".......kGk..", "......kGk...", ".....kGk....", "....kGk.....", "..kkGk......",
     ".kssGk......", ".ksssk......", "..kkk.......", "............"},
    {"............", ".....kk.....", "....kGGk....", ".....kk.....", "...kkkkkk...", "..kGGGgggk..", ".kGGgggggggk", "kkkkkkkkkkkk",
     "kGGGGGGGGGGk", ".kkkkkkkkkk.", "............", "............"},
    {"............", ".kk..kk..kk.", "kbbkkbbkkbbk", "krrkkyykknnk", "krrkkyykknnk", "krrkkyykknnk", "kkkkkkkkkkkk", "kbbbbbbbbbbk",
     "kbbbbbbbbbbk", ".kkkkkkkkkk.", "............", "............"},
    {"..e...e.....", "...e...e....", "..e...e.....", ".kkkkkkkkkk.", "kssssssssssk", "kwwwwwwwwwwk", "kwwwwwwwwwwk", ".kwwwwwwwwk.",
     "..kwwwwwwk..", "..kkkkkkkk..", ".kttttttttk.", "kkkkkkkkkkkk"},
    {".....o......", ".....oo.....", "....ooo.....", "...oooyo....", "...ooyyoo...", "..oooyyyoo..", "..ooyyyyoo..", "..oyyYyyyo..",
     "..oyyyyyyo..", "...oyyyyo...", "kbbkkbbkkbbk", "kbbbbbbbbbbk"},
};
static Color kit_pal(char c) {
    switch (c) {
    case 'k': return (Color){34, 20, 16, 255};
    case 'g': return (Color){92, 96, 108, 255};
    case 'G': return (Color){158, 164, 176, 255};
    case 'c': return (Color){190, 104, 56, 255};
    case 'C': return (Color){242, 164, 98, 255};
    case 'w': return (Color){252, 242, 220, 255};
    case 'r': return (Color){214, 64, 52, 255};
    case 'o': return (Color){240, 108, 30, 255};
    case 'y': return (Color){255, 214, 70, 255};
    case 'Y': return (Color){255, 248, 190, 255};
    case 'b': return (Color){124, 78, 46, 255};
    case 't': return (Color){218, 174, 112, 255};
    case 's': return (Color){232, 92, 50, 255};
    case 'n': return (Color){110, 184, 92, 255};
    default: return (Color){236, 236, 244, 255};   /* e: steam */
    }
}
static void kit_icon(int k, float x, float y, float px, int grey) {
    int r, c;
    for (r = 0; r < 12; r++)
        for (c = 0; c < 12; c++) {
            char ch = KIT_ICO[k][r][c];
            Color col;
            int x1 = (int)(x + c * px), y1 = (int)(y + r * px);
            if (ch == '.') continue;
            col = kit_pal(ch);
            if (grey) col = mixc(col, (Color){130, 120, 120, 255}, 0.7f);
            DrawRectangle(x1, y1, (int)(x + (c + 1) * px) - x1, (int)(y + (r + 1) * px) - y1, col);
        }
}

/* the halfway kitchen: a wooden wall under a striped awning, three recipe cards pinned to a brass rail, and
   a counter with the stove knobs below. Each card has a keycap, a pixel icon, a name, what it does and a chalk
   price tag; a click or a key does the same thing. 4 or Esc leaves (the flap on the counter). */
static void draw_kitchen_modal(void) {
    float W = (float)GetScreenWidth(), H = (float)GetScreenHeight(), u = uis;
    float mw = fminf(780 * u, W - 24 * u), mh, x0, y0;
    float gap = 14 * u, cw = (mw - 40 * u - 2 * gap) / 3, cy, ch, lh = fmaxf(20 * u, 15.0f), ph, pw = 124 * u, dy;
    Color body = {122, 78, 52, 255}, edge = {70, 42, 28, 255}, deep = {46, 28, 20, 255}, accent = {255, 214, 70, 255};
    Color cream = {255, 240, 214, 255}, brass = {196, 150, 66, 255}, ink = {62, 38, 26, 255}, inkd = {104, 72, 52, 255};
    Color grey = {140, 130, 124, 255};
    int i, f1 = fs(20), nf = fs(20), ml = 1, fd = fs(10);
    float sw, ay, nh, fw = fmaxf(300 * u, 52 * u + text_w("LEAVE THE KITCHEN", f1) + 24 * u);
    /* one name size for all three cards, one row of text sized to the tallest description, and a card tall enough for both */
    for (i = 0; i < 3; i++) {
        char d[160], pr[24];
        kit_desc(i, d, sizeof d);
        if (wrap_lines(d, cw - 28 * u, fd) > ml) ml = wrap_lines(d, cw - 28 * u, fd);
        while (nf > 10 && text_w(KIT_NAME[kit[i].k], nf) > cw - 20 * u) nf -= 10;
        snprintf(pr, sizeof pr, P.kit[kit[i].k] ? "BOUGHT" : "%d gold", KIT_PRICE[kit[i].k]);
        pw = fmaxf(pw, text_w(pr, f1) + 24 * u);
    }
    pw = fminf(pw, cw - 12 * u);
    nh = 11.0f * font_mul(nf);
    ph = fmaxf(34 * u, 11.0f * font_mul(f1) + 14 * u);
    dy = 4 * u + 62 * u + nh + 14 * u;
    ch = fmaxf(262 * u, dy + ml * lh + 8 * u + ph + 12 * u + 4 * u);
    mh = 148 * u + ch + 90 * u;
    x0 = (W - mw) / 2; y0 = (H - mh) / 2; cy = y0 + 148 * u; ay = y0 + 10 * u;
    dim(0.6f);
    DrawRectangleRounded((Rectangle){x0 - 5 * u, y0 - 5 * u, mw + 10 * u, mh + 10 * u}, 0.05f, 6, (Color){34, 24, 38, 255});
    DrawRectangleRounded((Rectangle){x0, y0, mw, mh}, 0.045f, 6, body);
    for (i = 0; i < 14; i++) {   /* wall planks */
        float px = x0 + 12 * u + i * ((mw - 24 * u) / 14);
        DrawRectangle((int)px, (int)(y0 + 14 * u), (int)(2 * u), (int)(mh - 28 * u), (Color){98, 60, 38, 150});
        DrawRectangle((int)(px + 3 * u), (int)(y0 + 90 * u + (i * 37 % 7) * 40 * u), (int)(5 * u), (int)(2 * u), (Color){98, 60, 38, 110});
    }
    /* the awning: red and cream stripes with a scalloped edge */
    sw = (mw - 28 * u) / 34;
    for (i = 0; i < 34; i++) {
        float ax = x0 + 14 * u + i * sw;
        Color sc = i % 2 ? (Color){255, 238, 206, 255} : (Color){206, 70, 56, 255};
        DrawRectangle((int)ax, (int)ay, (int)ceilf(sw), (int)(10 * u), sc);
        DrawCircle((int)(ax + sw / 2), (int)(ay + 10 * u), sw / 2, sc);
    }
    DrawRectangle((int)(x0 + 14 * u), (int)ay, (int)(mw - 28 * u), (int)(3 * u), alphac(WHITE, 0.3f));
    text_c("HALFWAY KITCHEN", W / 2 + 2 * u, y0 + 32 * u, fs(40), (Color){40, 22, 14, 255});
    text_c("HALFWAY KITCHEN", W / 2, y0 + 30 * u, fs(40), cream);
    text_c("Floor 5 is next. Upgrades last this run.", W / 2, y0 + 100 * u, f1, (Color){246, 226, 196, 255});
    /* the brass rail the cards hang from */
    DrawRectangle((int)(x0 + 14 * u), (int)(cy - 12 * u), (int)(mw - 28 * u), (int)(5 * u), brass);
    DrawRectangle((int)(x0 + 14 * u), (int)(cy - 12 * u), (int)(mw - 28 * u), (int)(2 * u), (Color){250, 214, 130, 255});
    DrawCircle((int)(x0 + 14 * u), (int)(cy - 9 * u), 5 * u, brass);
    DrawCircle((int)(x0 + mw - 14 * u), (int)(cy - 9 * u), 5 * u, brass);
    for (i = 0; i < 3; i++) {
        float x = x0 + 20 * u + i * (cw + gap), mid = x + cw / 2, ty = cy + 4 * u;
        int sold = P.kit[kit[i].k], on = hot_add((Rectangle){x, cy, cw, ch}, KEY_ONE + i) && !sold;
        int ok = P.gold >= KIT_PRICE[kit[i].k];
        char label[2] = {(char)('1' + i), 0}, desc[160], pr[24];
        Color paper = sold ? (Color){206, 196, 184, 255} : on ? (Color){255, 248, 226, 255} : (Color){248, 232, 198, 255};
        DrawRectangle((int)(x + cw * 0.25f), (int)(cy - 8 * u), (int)(2 * u), (int)(12 * u), brass);
        DrawRectangle((int)(x + cw * 0.75f), (int)(cy - 8 * u), (int)(2 * u), (int)(12 * u), brass);
        DrawRectangleRounded((Rectangle){x + 3 * u, ty + 5 * u, cw, ch - 4 * u}, 0.05f, 6, (Color){20, 10, 8, 110});
        DrawRectangleRounded((Rectangle){x, ty, cw, ch - 4 * u}, 0.05f, 6, paper);
        DrawRectangleRoundedLinesEx((Rectangle){x, ty, cw, ch - 4 * u}, 0.05f, 6, (on ? 3 : 2) * u, on ? accent : (Color){168, 122, 80, 255});
        DrawCircle((int)(x + cw * 0.25f), (int)(ty + 4 * u), 3 * u, sold ? grey : (Color){200, 60, 50, 255});
        DrawCircle((int)(x + cw * 0.75f), (int)(ty + 4 * u), 3 * u, sold ? grey : (Color){200, 60, 50, 255});
        keycap(x + 10 * u, ty + 14 * u, 28 * u, label, sold ? grey : accent);
        kit_icon(kit[i].k, mid - 24 * u, ty + 12 * u, 4 * u, sold);
        text_c(KIT_NAME[kit[i].k], mid, ty + 62 * u, nf, sold ? grey : ink);
        DrawRectangle((int)(x + 14 * u), (int)(ty + dy - 8 * u), (int)(cw - 28 * u), (int)(2 * u), sold ? grey : (Color){214, 120, 100, 255});
        kit_desc(i, desc, sizeof desc);
        wrap_text(desc, x + 14 * u, ty + dy, cw - 28 * u, fd, sold ? grey : inkd, lh);
        snprintf(pr, sizeof pr, sold ? "BOUGHT" : "%d gold", KIT_PRICE[kit[i].k]);
        DrawRectangleRounded((Rectangle){mid - pw / 2 - 4 * u, cy + ch - 12 * u - ph, pw + 8 * u, ph}, 0.25f, 4, (Color){138, 96, 58, 255});
        DrawRectangleRounded((Rectangle){mid - pw / 2, cy + ch - 8 * u - ph, pw, ph - 8 * u}, 0.25f, 4, (Color){30, 40, 36, 255});
        text_cc(pr, mid, cy + ch - 12 * u - ph / 2, f1, sold ? (Color){120, 130, 124, 255} : ok ? (Color){190, 255, 200, 255} : (Color){255, 150, 130, 255});
    }
    {   /* the counter: butcher-block top, cabinet front with stove knobs and drawer pulls */
        float ky = y0 + mh - 72 * u, kx = x0 + 12 * u, kw = mw - 24 * u, fl = fx_on ? sinf(g_time * 9) * 0.5f + sinf(g_time * 5.3f) * 0.5f : 0;
        DrawRectangle((int)kx, (int)ky, (int)kw, (int)(9 * u), (Color){222, 176, 116, 255});
        DrawRectangle((int)kx, (int)ky, (int)kw, (int)(2 * u), (Color){250, 214, 156, 255});
        DrawRectangle((int)kx, (int)(ky + 9 * u), (int)kw, (int)(54 * u), (Color){92, 56, 38, 255});
        DrawRectangle((int)kx, (int)(ky + 9 * u), (int)kw, (int)(3 * u), (Color){52, 30, 20, 255});
        for (i = 0; i < 3 && kx + 170 * u <= x0 + mw / 2 - fw / 2 - 6 * u; i++) {   /* knobs */
            float kcx = kx + 30 * u + i * 34 * u, kcy = ky + 36 * u;
            DrawCircle((int)kcx, (int)(kcy + 2 * u), 11 * u, (Color){30, 18, 14, 255});
            DrawCircle((int)kcx, (int)kcy, 10 * u, (Color){60, 62, 70, 255});
            DrawCircle((int)kcx, (int)kcy, 7 * u, (Color){118, 122, 134, 255});
            DrawRectangle((int)(kcx - u), (int)(kcy - 9 * u), (int)(2 * u), (int)(9 * u), (Color){250, 90, 60, 255});
        }
        if (kx + 170 * u <= x0 + mw / 2 - fw / 2 - 6 * u) {   /* a little fire in the stove */
            float r = 26 * u + fl * 3 * u;
            DrawTexturePro(glow_tex, (Rectangle){0, 0, 64, 64}, (Rectangle){kx + 150 * u - r, ky + 38 * u - r, r * 2, r * 2},
                           (Vector2){0, 0}, 0, (Color){255, 140, 50, 120});
            kit_icon(K_HEARTH, kx + 132 * u, ky + 18 * u - fl * 1.5f * u, 3 * u, 0);
        }
        for (i = 0; i < 2; i++) {   /* drawers on the right, when the flap leaves room */
            float dx = kx + kw - 190 * u + i * 92 * u;
            if (dx < x0 + mw / 2 + fw / 2 + 8 * u) continue;
            DrawRectangleRounded((Rectangle){dx, ky + 18 * u, 84 * u, 36 * u}, 0.15f, 4, (Color){112, 70, 46, 255});
            DrawRectangleRoundedLinesEx((Rectangle){dx, ky + 18 * u, 84 * u, 36 * u}, 0.15f, 4, 2 * u, (Color){52, 30, 20, 255});
            DrawRectangleRounded((Rectangle){dx + 26 * u, ky + 30 * u, 32 * u, 6 * u}, 0.5f, 4, brass);
        }
    }
    {   /* the flap: 4 or Esc leaves for floor 5 */
        float fx = x0 + mw / 2 - fw / 2, fy = y0 + mh - 62 * u, oy;
        int on = hot_add((Rectangle){fx, fy, fw, 46 * u}, KEY_FOUR), down = on && IsMouseButtonDown(MOUSE_BUTTON_LEFT);
        oy = down ? 3 * u : 0;
        pix_btn((Rectangle){fx, fy, fw, 46 * u}, on ? (Color){96, 64, 44, 255} : deep, on ? accent : brass, down);
        keycap(fx + 12 * u, fy + 8 * u + oy, 28 * u, "4", accent);
        text("LEAVE THE KITCHEN", fx + 52 * u, fy + 10 * u + oy, f1, cream);
    }
    (void)edge;
}

static void draw_help(void) {
    static const char *L[] = {
        "Move ........ WASD, arrow keys, or HJKL",
        "SoupBonk ........ walk into it: a weak ladle bonk, free",
        "Wait ........ space or .",
        "Descend ..... E or > while on stairs",
        "Stairs ...... two per floor. Labels name the next rule.",
        "Vending ..... bump it: 1-3 buy, 4 walks away",
        "Kitchen ..... between floors 4 and 5: 1-3 buy upgrades, 4 leaves",
        "Use item .... 1-9",
        "Slurp ....... R        heals you, costs 15 warmth",
        "Splash ...... F        every foe next to you, costs 12 warmth",
        "Fling ....... T, then a direction, costs 8 warmth",
        "You ......... C        Mute ........ M",
        "Quit ........ Esc or Q",
        "Soup ........ Tomato, Miso or Chowder. Pick one on the start screen.",
        "",
        "Reach floor 8. Beat the duck. Get the spoon.",
        "The duck has three phases. Watch its tail feathers.",
        "Your soup cools every few turns. Keep it warm.",
        "Warm rocks wait in your pockets. Use them when you need heat.",
        "Warmth decides soup hits: 80+ burns, 50+ hits, 25+ sticks, under 25 is too cold.",
        "Kills warm the soup. Spilled soup puddles burn or stick what steps in them.",
        "Monsters drop ingredients. Use one to change your soup's recipe.",
        "Every floor has a rule. Read the top left.",
        "Hover the mouse over things to learn what they are.",
        "Monsters react to soup: hover them to learn how.",
        "Potions are not always potions.",
    };
    dim(0.6f);
    draw_box_lines("HOW TO SOUP", L, (int)(sizeof L / sizeof L[0]), (Color){252, 212, 78, 255});
}

static void draw_char(void) {
    char L[48][192];
    const char *lp[48];
    int ic[48];
    int n = 0, i, any = 0, ings = 0;
    snprintf(L[n++], 192, "Level %d   XP %d/%d   Kills %d", P.lvl, P.xp, need(P.lvl), P.kills);
    snprintf(L[n++], 192, "HP %d/%d   ATK %d   DEF %d   Gold %d", P.hp, P.maxhp, P.atk, P.def, P.gold);
    snprintf(L[n++], 192, "Turn %d   Seed %lu", P.turns, g_seed);
    snprintf(L[n++], 192, "Soup: %s. %s", SOUP_NAME[soup_kind], SOUP_DESC[soup_kind]);
    if (P.ladle || P.lids) snprintf(L[n++], 192, "Gear: %d ladle (splash +%d), %d lid (cools slower)", P.ladle, 3 * P.ladle, P.lids);
    L[n++][0] = 0;
    snprintf(L[n++], 192, "PERKS");
    for (i = 0; i < PK_N; i++) {
        if (!P.perk[i]) continue;
        any = 1;
        if (P.perk[i] > 1) snprintf(L[n++], 192, "  %s x%d - %s", PERK_NAME[i], P.perk[i], PERK_DESC[i]);
        else snprintf(L[n++], 192, "  %s - %s", PERK_NAME[i], PERK_DESC[i]);
    }
    if (!any) snprintf(L[n++], 192, "  None yet. You are perk-less.");
    {
        int bought = 0;
        for (i = 0; i < K_N; i++) {
            if (!P.kit[i]) continue;
            if (!bought) { L[n++][0] = 0; snprintf(L[n++], 192, "KITCHEN UPGRADES"); }
            bought = 1;
            snprintf(L[n++], 192, "  %s - %s", KIT_NAME[i], KIT_DESC[i]);
        }
    }
    L[n++][0] = 0;
    snprintf(L[n++], 192, "SOUP RECIPE");
    for (i = 0; i < n; i++) ic[i] = -1;
    for (i = 0; i < G_N; i++)
        if (P.ing[i]) { snprintf(L[n], 192, "%s - %s", ING_NAME[i], ING_SHORT[i]); ic[n++] = P.ing[i] << 8 | i; ings = 1; }
    for (i = 0; i < C_N; i++)
        if (combo_on(i)) { snprintf(L[n], 192, "%s - %s", COMBO[i].name, COMBO[i].desc); ic[n++] = -2 - i; ings = 1; }
    if (!ings) snprintf(L[n], 192, "  Plain soup. Nothing in it yet."), ic[n++] = -1;
    for (i = 0; i < n; i++) lp[i] = L[i];
    dim(0.6f);
    draw_box_icons("YOU, A SOUP HAVER", lp, ic, n, (Color){252, 212, 78, 255});
}

static void draw_confirm(void) {
    static const char *L[] = {"Your soup will be sad.", "", ""};
    static const char *lab[] = {"Abandon it", "Keep going"}, *cap[] = {"Y", "N"};
    static const int key[] = {KEY_Y, KEY_N};
    dim(0.6f);
    draw_box_lines("ABANDON YOUR SOUP?", L, 3, (Color){255, 140, 100, 255});
    box_buttons(lab, cap, key, 2);
}

static void draw_end(void) {
    static const char *EPI[] = {
        "Your soup will be eaten by someone else. Probably a duck.",
        "At least the soup was good. Allegedly.",
        "Here lies you. Spoonless.",
        "You died as you lived: holding soup.",
        "The duck remains undefeated. The duck is smug about it.",
    };
    char L[10][128];
    const char *lp[10], *title;
    int n = 0, i, score, rating;
    Color tc;
    score = P.gold + P.kills * 10 + (P.floor - 1) * 100 + (state == S_WON ? 1000 + P.soup * 10 : 0);
    switch (state) {
    case S_DEAD:
        title = "YOU DIED";
        tc = (Color){255, 90, 80, 255};
        snprintf(L[n++], 128, "Defeated by %s on floor %d.", cause, P.floor);
        snprintf(L[n++], 128, "%s", EPI[(g_seed + (unsigned long)P.turns) % 5]);
        break;
    case S_COLD:
        title = "YOUR SOUP WENT COLD";
        tc = (Color){140, 190, 255, 255};
        snprintf(L[n++], 128, "It happened on floor %d. You eat it anyway.", P.floor);
        snprintf(L[n++], 128, "It is sad soup. (._.)");
        break;
    case S_WON:
        rating = (P.soup + 9) / 10;
        title = "VICTORY (AND SOUP)";
        tc = (Color){252, 212, 78, 255};
        snprintf(L[n++], 128, "You claim the Golden Spoon and eat your soup.");
        snprintf(L[n++], 128, "It is %s (%d/100). Rating: %d/10.", soup_word(P.soup), P.soup, rating);
        snprintf(L[n++], 128, "%s",
                 rating >= 9 ? "Chef's kiss. \\(^o^)/" : rating >= 6 ? "Pretty good soup, honestly."
                 : rating >= 3 ? "Edible. Technically." : "Soup-adjacent. You eat it with a golden spoon anyway.");
        break;
    default:
        title = "YOU GAVE UP";
        tc = (Color){200, 190, 180, 255};
        snprintf(L[n++], 128, "You abandon your soup on floor %d. It understands.", P.floor);
        break;
    }
    L[n++][0] = 0;
    snprintf(L[n++], 128, "Level %d   Kills %d   Gold %d   Turns %d", P.lvl, P.kills, P.gold, P.turns);
    snprintf(L[n++], 128, "Score %d   Seed %lu", score, g_seed);
    L[n++][0] = 0;
    L[n++][0] = 0;
    L[n++][0] = 0;
    for (i = 0; i < n; i++) lp[i] = L[i];
    dim(0.5f);
    draw_box_lines(title, lp, n, tc);
    {
        static const char *lab[] = {"New run", "Same seed"}, *cap[] = {"R", "S"};
        static const int key[] = {KEY_R, KEY_S};
        box_buttons(lab, cap, key, 2);
    }
}

static void draw_title(void) {
    float W = (float)GetScreenWidth(), H = (float)GetScreenHeight(), u = uis;
    const char *t = "SOUPLIKE";
    int sz = fs(110), k;
    float tw, xx, bs = 11 * u, bx, by;
    DrawRectangleGradientV(0, 0, (int)W, (int)H, (Color){40, 22, 38, 255}, (Color){10, 6, 14, 255});
    for (k = 0; k < 24; k++) {   /* drifting dust */
        float px = fmodf(k * 173.0f + g_time * (8 + k % 5) * u, W), py = fmodf(k * 97.0f - g_time * (5 + k % 3) * u + H * 2, H);
        DrawCircle((int)px, (int)py, (1 + k % 3) * u, (Color){255, 200, 150, 30});
    }
    tw = text_w(t, sz);
    xx = W / 2 - tw / 2;
    for (k = 0; t[k]; k++) {
        char c1[2] = {t[k], 0};
        float yy = H * 0.1f + sinf(g_time * 2.6f + k * 0.55f) * 10 * u;
        text(c1, xx, yy, sz, mixc((Color){255, 236, 140, 255}, (Color){255, 120, 60, 255}, k / 7.0f));
        xx += text_w(c1, sz) + sz / 10.0f;
    }
    bx = W / 2 - 12 * bs;
    by = H * 0.42f;
    if (frand() < 0.7f)
        upart_add(bx + frange(30, 230) * u, by + 6 * u, frange(-8, 8) * u, frange(-60, -35) * u, frange(1.0f, 1.8f),
                  frange(6, 11) * u, (Color){255, 248, 240, 60});
    update_draw_uparts(GetFrameTime());
    draw_spr_ui(SP_BOWL, bx, by, bs, WHITE, soup_col(100));
    {
        Sprite *d = &spr[SP_DUCK];
        float ds = 9 * u, dx = W / 2 + 190 * u, dy = by - 30 * u + sinf(g_time * 2) * 4 * u;
        DrawTexturePro(d->tex, (Rectangle){0, 0, (float)-d->w, (float)d->h}, (Rectangle){dx, dy, d->w * ds, d->h * ds},
                       (Vector2){0, 0}, 0, WHITE);
        if (fmodf(g_time, 4.0f) < 0.6f) text("HONK", dx + 60 * u, dy - 30 * u, fs(30), (Color){255, 220, 90, 200});
    }
    text_c("You made soup. It is perfect. You have no spoon.", W / 2, H * 0.66f, fs(20), (Color){230, 220, 210, 255});
    text_c("The last spoon in the world is held by a VERY LARGE DUCK", W / 2, H * 0.66f + 34 * u, fs(20), (Color){230, 220, 210, 255});
    text_c("at the bottom of an 8-floor dungeon. Your soup is getting cold.", W / 2, H * 0.66f + 68 * u, fs(20), (Color){230, 220, 210, 255});
    text_c("press any key", W / 2, H * 0.83f, fs(30), alphac((Color){252, 212, 78, 255}, 0.55f + 0.45f * sinf(g_time * 4)));
    {
        char b[64];
        snprintf(b, sizeof b, "seed %lu", g_seed);
        text_c(b, W / 2, H - 34 * u, fs(20), (Color){120, 100, 110, 255});
    }
}

/* ------------------------------------------------------------ input --- */

static int held_dir = -1;
static float held_t;

/* The page's buttons feed the same input path as the keyboard. */
#if defined(__EMSCRIPTEN__)
static int web_pending_key;
EMSCRIPTEN_KEEPALIVE void web_key(int key) { web_pending_key = key; }
/* The page reports each canvas click (as a fraction of its size), so a quick click on a slow
   frame is never lost between two polls of the mouse button. */
static int web_click_pending;
static Vector2 web_click_at;
EMSCRIPTEN_KEEPALIVE void web_click(float fx, float fy) {
    web_click_pending = 1;
    web_click_at = (Vector2){fx * GetScreenWidth(), fy * GetScreenHeight()};
}
EMSCRIPTEN_KEEPALIVE int web_info(int field) {
    switch (field) {
    case 0: return ui;
    case 1: return P.turns;
    case 2: return P.floor;
    case 3: return state;
    case 4: return ninv;
    case 5: return muted;
    case 6: return (int)g_seed;
    case 7: return P.hp;
    case 8: return P.maxhp;
    case 9: return P.soup;
    case 10: return P.gold;
    case 11: return P.atk;
    case 12: return P.def;
    case 13: return P.lvl;
    case 14: return P.xp;
    case 15: return rule;
    case 16: return P.kills;
    case 17: return compact_ui();
    case 19: return pscale;   /* camera zoom, and the world view in world pixels */
    case 20: return vw;
    case 21: return vh;
    case 18: return P.gold + P.kills * 10 + (P.floor - 1) * 100 + (state == S_WON ? 1000 + P.soup * 10 : 0);
    case 22: return soup_kind;
    case 23: return (int)hud_top_bottom;
    case 24: return banner_t < 3.2f || phase_t < 3.2f;   /* a floor or duck-phase banner is on screen */
    default: return 0;
    }
}
EMSCRIPTEN_KEEPALIVE void web_resize(int w, int h) {
    if (w >= 240 && h >= 160 && (w != GetScreenWidth() || h != GetScreenHeight()))
        SetWindowSize(w, h);
}
EMSCRIPTEN_KEEPALIVE const char *web_text(int kind, int index) {
    static char buf[256];
    buf[0] = 0;
    switch (kind) {
    case 0: return RULE_NAME[rule];
    case 1: return RULE_DESC[rule];
    case 2:
        if (index >= 0 && index < ninv) pocket_name(inv[index], buf, sizeof buf, 1);
        break;
    case 3:
        if (index >= 0 && index < nlog) return logm[nlog - 1 - index];
        break;
    case 4:
        if (index >= 0 && index < nperk_opts) return PERK_NAME[perk_opts[index]];
        break;
    case 5:
        if (index >= 0 && index < nperk_opts) return PERK_DESC[perk_opts[index]];
        break;
    case 6:
        if (index >= 0 && index < PK_N && P.perk[index])
            snprintf(buf, sizeof buf, "%s x%d: %s", PERK_NAME[index], P.perk[index], PERK_DESC[index]);
        break;
    case 7:
        if (state == S_DEAD) return "YOU DIED";
        if (state == S_COLD) return "YOUR SOUP WENT COLD";
        if (state == S_WON) return "VICTORY (AND SOUP)";
        return "YOU GAVE UP";
    case 9:   /* vending offer name */
        if (index >= 0 && index < 3) {
            if (offers[index].moody) return "???";
            pocket_name((Pocket){offers[index].k, offers[index].sub}, buf, sizeof buf, 1);
        }
        break;
    case 10:  /* vending offer price */
        if (index >= 0 && index < 3) {
            if (offers[index].sold) return "sold out";
            snprintf(buf, sizeof buf, "%d gold", offer_price(&offers[index]));
        }
        break;
    case 8:
        if (state == S_DEAD) snprintf(buf, sizeof buf, "Defeated by %s on floor %d.", cause, P.floor);
        else if (state == S_WON) snprintf(buf, sizeof buf, "You claim the Golden Spoon! Your soup is %s (%d/100).", soup_word(P.soup), P.soup);
        else if (state == S_COLD) snprintf(buf, sizeof buf, "It happened on floor %d. You eat it anyway. It is sad soup.", P.floor);
        else snprintf(buf, sizeof buf, "You abandon your soup on floor %d. It understands.", P.floor);
        break;
    case 16: return BAND_NAME[soup_band(P.soup)];   /* the soup's band, for the compact HUD */
    case 12: if (index >= 0 && index < G_N) return ING_NAME[index]; break;
    case 13: if (index >= 0 && index < G_N) return ING_SHORT[index]; break;
    case 14: if (index >= 0 && index < C_N) return COMBO[index].name; break;
    case 15: if (index >= 0 && index < C_N) return COMBO[index].desc; break;
    case 30:   /* kitchen offer name */
        if (index >= 0 && index < 3) return KIT_NAME[kit[index].k];
        break;
    case 31:   /* kitchen offer description */
        if (index >= 0 && index < 3) kit_desc(index, buf, sizeof buf);
        break;
    case 32:   /* kitchen offer price, or bought */
        if (index >= 0 && index < 3) {
            if (P.kit[kit[index].k]) return "bought";
            snprintf(buf, sizeof buf, "%d gold", KIT_PRICE[kit[index].k]);
        }
        break;
    case 33: {   /* the index-th upgrade bought this run, for the character sheet */
        int j, seen = 0;
        for (j = 0; j < K_N; j++)
            if (P.kit[j] && seen++ == index) { snprintf(buf, sizeof buf, "%s - %s", KIT_NAME[j], KIT_DESC[j]); break; }
        break;
    }
    case 11:  /* soup recipe */
        recipe_str(buf, sizeof buf);
        break;
    /* cookbook names and descriptions by index, and the starting soups */
    case 17: if (index >= 0 && index < G_N) return ING_DESC[index]; break;
    case 18: if (index >= 0 && index < M_N) return MT[index].name; break;
    case 19: if (index >= 0 && index < R_N) return RULE_NAME[index]; break;
    case 20: if (index >= 0 && index < SOUP_N) return SOUP_NAME[index]; break;
    case 21: if (index >= 0 && index < SOUP_N) return SOUP_DESC[index]; break;
    }
    return buf;
}
/* The page draws the same sprites: web_icon says which sprite and tint a pocket, offer or ingredient uses,
   web_sprite hands back its RGBA pixels (16x16, mask pixels in the tint). */
EMSCRIPTEN_KEEPALIVE int web_icon(int src, int index, int field) {
    int k = -1, sub = 0, id = -1;
    Color tint = WHITE;
    if (src == 0 && index >= 0 && index < ninv) { k = inv[index].k; sub = inv[index].sub; }
    else if (src == 1 && index >= 0 && index < 3 && !offers[index].sold && !offers[index].moody) { k = offers[index].k; sub = offers[index].sub; }
    else if (src == 2 && index >= 0 && index < G_N) { k = I_INGREDIENT; sub = index; }
    if (k == I_POTION) { id = SP_POTION; tint = LOOK_COL[pot_look[sub]]; }
    else if (k == I_INGREDIENT) id = SP_ING + sub;
    else if (k == I_ROCK) id = SP_ROCK;
    else if (k == I_SCROLL) id = SP_SCROLL;
    return field == 0 ? id : (tint.r << 16 | tint.g << 8 | tint.b);
}
EMSCRIPTEN_KEEPALIVE const unsigned char *web_sprite(int id, int rgb) {
    static unsigned char px[16 * 16 * 4];
    int x, y;
    memset(px, 0, sizeof px);
    if (id < 0 || id >= SP_N || SPR_SRC[id].w > 16 || SPR_SRC[id].h > 16) return px;
    for (y = 0; y < SPR_SRC[id].h; y++) {
        int len = (int)strlen(SPR_SRC[id].rows[y]);
        for (x = 0; x < SPR_SRC[id].w && x < len; x++) {
            char ch = SPR_SRC[id].rows[y][x];
            Color c = ch == 'q' ? (Color){(unsigned char)(rgb >> 16), (unsigned char)(rgb >> 8), (unsigned char)rgb, 255} : pal(ch);
            unsigned char *o = px + (y * 16 + x) * 4;
            o[0] = c.r; o[1] = c.g; o[2] = c.b; o[3] = c.a;
        }
    }
    return px;
}
/* recipe facts for the page: kind 0 = stack count of ingredient index, 1 = combo state (2 active, 1 half-made) */
EMSCRIPTEN_KEEPALIVE int web_recipe(int kind, int index) {
    if (kind == 0 && index >= 0 && index < G_N) return P.ing[index];
    if (kind == 1 && index >= 0 && index < C_N) return combo_on(index) ? 2 : (P.ing[COMBO[index].a] || P.ing[COMBO[index].b]) ? 1 : 0;
    return 0;
}
EMSCRIPTEN_KEEPALIVE void web_release(void) { web_pending_key = 0; held_dir = -1; held_t = 0; }
/* Page settings. The page sends these before a run starts and whenever a setting changes. */
EMSCRIPTEN_KEEPALIVE void web_set_seed(int seed) { g_seed = (unsigned long)seed; }
EMSCRIPTEN_KEEPALIVE void web_set_volume(int percent) {
    master_vol = clampf(percent / 100.0f, 0, 1);
    if (audio_ok) SetMasterVolume(master_vol);
}
EMSCRIPTEN_KEEPALIVE void web_set_muted(int on) { muted = on != 0; }
EMSCRIPTEN_KEEPALIVE void web_set_effects(int on) {
    fx_on = on != 0;
    if (!fx_on) shake = hurt_flash = 0;
}
EMSCRIPTEN_KEEPALIVE void web_set_difficulty(int level) { cool_level = level < 0 ? 0 : level > 2 ? 2 : level; }
/* The starting soup for the next run (see SOUP_NAME). Out-of-range values fall back to Tomato. */
EMSCRIPTEN_KEEPALIVE void web_set_soup(int kind) { soup_kind = kind < 0 || kind >= SOUP_N ? SOUP_TOMATO : kind; }
/* The next cookbook discovery (kind * 100 + index, see cb_note), or -1 when the queue is empty. */
EMSCRIPTEN_KEEPALIVE int web_event(void) { return cb_take(); }
static int next_key(void) {
    if (click_key) { int k = click_key; click_key = 0; return k; }
    if (web_pending_key) { int k = web_pending_key; web_pending_key = 0; return k; }
    return GetKeyPressed();
}
EM_JS(int, page_active, (void), { return !document.hidden && document.hasFocus() && document.activeElement === Module.canvas; });
#else
static int next_key(void) {
    if (click_key) { int k = click_key; click_key = 0; return k; }
    return GetKeyPressed();
}
#endif

static int key_dir(int key) {
    switch (key) {
    case KEY_W: case KEY_UP: case KEY_K: return 0;
    case KEY_S: case KEY_DOWN: case KEY_J: return 1;
    case KEY_A: case KEY_LEFT: case KEY_H: return 2;
    case KEY_D: case KEY_RIGHT: case KEY_L: return 3;
    }
    return -1;
}

static int dir_down(int d) {
    static const int K[4][3] = {{KEY_W, KEY_UP, KEY_K}, {KEY_S, KEY_DOWN, KEY_J},
                                {KEY_A, KEY_LEFT, KEY_H}, {KEY_D, KEY_RIGHT, KEY_L}};
    return IsKeyDown(K[d][0]) || IsKeyDown(K[d][1]) || IsKeyDown(K[d][2]);
}

static void start_run(unsigned long seed) {
    g_seed = seed;
    cb_note(CB_RUN, 0);
    new_game();
    ui = UI_PLAY;
    end_delay = 0;
    held_dir = -1;
}

static void update_input(float dt) {
    int ch, key, clicked;
    Vector2 cp;
#if defined(__EMSCRIPTEN__)
    if (!page_active()) {
        web_release();
        while (GetKeyPressed()) {}
        while (GetCharPressed()) {}
        return;
    }
#endif
#if defined(__EMSCRIPTEN__)
    clicked = web_click_pending;
    cp = web_click_at;
    web_click_pending = 0;
#else
    clicked = IsMouseButtonPressed(MOUSE_BUTTON_LEFT);
    cp = GetMousePosition();
#endif
    if (clicked) {
        int i;
        for (i = nhot - 1; i >= 0; i--)
            if (CheckCollisionPointRec(cp, hot_rects[i].r)) { click_key = hot_rects[i].key; break; }
    }
    switch (ui) {
    case UI_TITLE:
        key = next_key();
        if (key || clicked) {
            if (key == KEY_ESCAPE) { quit_now = 1; break; }
            play(SFX_SELECT);
            start_run(g_seed);
        }
        break;
    case UI_PLAY:
        if (state != S_PLAY) {
            while (next_key()) {}
            while (GetCharPressed()) {}
            break;
        }
        while ((ch = GetCharPressed()) != 0) {
            if (ch == '>') do_turn('>');
            else if (ch == '?') { ui = UI_HELP; return; }
            else if (ch == '.') do_turn('.');
            if (state != S_PLAY || pending_levels || vend_open || kitchen_open) return;
        }
        while ((key = next_key()) != 0) {
            int d = key_dir(key);
            if (aim) {   /* after T, the next direction flings the soup */
                if (d >= 0) { aim = 0; do_turn("WSAD"[d]); }
                else if (key == KEY_ESCAPE || key == KEY_T) { aim = 0; msg("You put the ladle down."); }
            } else if (d >= 0) {
                do_turn("wsad"[d]);
                held_dir = d;
                held_t = 0;
            } else {
                switch (key) {
                case KEY_ESCAPE: case KEY_Q: ui = UI_CONFIRM; return;
                case KEY_R: do_turn('r'); break;
                case KEY_F: do_turn('f'); break;
                case KEY_T:
                    aim = 1;
                    msg("Fling which way? Pick a direction. Esc cancels.");
                    break;
                case KEY_C: ui = UI_CHAR; return;
                case KEY_F1: case KEY_TAB: ui = UI_HELP; return;
                case KEY_M:
                    muted = !muted;
                    msg(muted ? "Sound off. The duck is disappointed." : "Sound on. HONK.");
                    break;
                case KEY_E: case KEY_ENTER: do_turn('>'); break;
                case KEY_SPACE: case KEY_Z: do_turn('.'); break;
                default:
                    if (key >= KEY_ONE && key <= KEY_NINE) do_turn('1' + (key - KEY_ONE));
                    break;
                }
            }
            if (state != S_PLAY || pending_levels || vend_open || kitchen_open) return;
        }
        if (held_dir >= 0) {   /* hold a direction to keep walking */
            if (!dir_down(held_dir)) held_dir = -1;
            else {
                held_t += dt;
                if (held_t > 0.24f) {
                    held_t -= 0.11f;
                    do_turn("wsad"[held_dir]);
                }
            }
        }
        break;
    case UI_PERK: {
        int pick = -1;
        while ((key = next_key()) != 0)
            if (key >= KEY_ONE && key < KEY_ONE + nperk_opts) pick = key - KEY_ONE;
        if (clicked && perk_hover >= 0) pick = perk_hover;
        else if (clicked)
            for (int q = 0; q < nperk_opts && q < 3; q++)
                if (CheckCollisionPointRec(cp, perk_rect[q])) pick = q;
        if (pick >= 0 && perk_anim > 0.3f) {
            apply_perk(perk_opts[pick]);
            fx_sparkle(P.x, P.y, 24, (Color){255, 220, 90, 255});
            play(SFX_SELECT);
            pending_levels--;
            if (pending_levels > 0) { roll_perks(); perk_anim = 0; }
            else ui = UI_PLAY;
            held_dir = -1;
        }
        break;
    }
    case UI_VEND: {   /* the vending menu: 1-3 buy, 4 or Esc walks away */
        int pick = -1;
        while (GetCharPressed()) {}
        while ((key = next_key()) != 0) {
            if (key >= KEY_ONE && key <= KEY_THREE) pick = key - KEY_ONE;
            else if (key == KEY_FOUR || key == KEY_ESCAPE) pick = 3;
        }
        if (pick == 3) {
            vend_open = 0;
            ui = UI_PLAY;
            held_dir = -1;
        } else if (pick >= 0 && buy_offer(pick)) {
            vend_open = 0;
            ui = UI_PLAY;
            held_dir = -1;
            do_turn('.');
        }
        break;
    }
    case UI_KITCHEN: {   /* the halfway kitchen: 1-3 buy an upgrade, 4 or Esc leaves for floor 5 */
        int leave = 0;
        while (GetCharPressed()) {}
        while ((key = next_key()) != 0) {
            if (key >= KEY_ONE && key <= KEY_THREE) buy_kit(key - KEY_ONE);
            else if (key == KEY_FOUR || key == KEY_ESCAPE) { leave = 1; break; }
        }
        if (leave) {
            kitchen_leave();
            ui = UI_PLAY;
            held_dir = -1;
        }
        break;
    }
    case UI_HELP:
    case UI_CHAR:
        while (GetCharPressed()) {}
        if (next_key() || clicked) { ui = UI_PLAY; held_dir = -1; }
        break;
    case UI_CONFIRM:
        while ((key = next_key()) != 0) {
            if (key == KEY_Y) { state = S_QUIT; ui = UI_END; play(SFX_DEATH); break; }
            if (key == KEY_N || key == KEY_ESCAPE) { ui = UI_PLAY; held_dir = -1; break; }
        }
        break;
    case UI_END:
        while ((key = next_key()) != 0) {
            if (key == KEY_R) {
                play(SFX_SELECT);
                start_run(((unsigned long)time(NULL) * 2654435761UL + (unsigned long)(g_time * 1000)) % 1000000UL);
                break;
            }
            if (key == KEY_S) { play(SFX_SELECT); start_run(g_seed); break; }
            if (key == KEY_ESCAPE) { quit_now = 1; break; }
        }
        break;
    }
}

/* ------------------------------------------------------------- main --- */

static void game_init(void) {
    int i;
#if defined(__EMSCRIPTEN__)
    /* The page owns canvas sizing; raylib's default web resize uses the whole window. */
    SetConfigFlags(FLAG_VSYNC_HINT);
#else
    SetConfigFlags(FLAG_WINDOW_RESIZABLE | FLAG_VSYNC_HINT);
#endif
    InitWindow(1280, 720, "SOUPLIKE");
#if !defined(__EMSCRIPTEN__)
    SetWindowMinSize(800, 450);
#endif
    SetExitKey(KEY_NULL);
    SetTargetFPS(60);
    InitAudioDevice();
    audio_ok = IsAudioDeviceReady();
    if (audio_ok) {
        for (i = 0; i < SFX_N; i++) sfx[i] = synth(i);
        SetSoundVolume(sfx[SFX_STEP], 0.35f);
        SetMasterVolume(master_vol);
    }
    font_init();
    build_sprites();
    build_themes();
    post = LoadShaderFromMemory(NULL, POST_FS);
    loc_texel = GetShaderLocation(post, "texel");
    loc_hurt = GetShaderLocation(post, "hurt");
    loc_cold = GetShaderLocation(post, "cold");
    loc_scale = GetShaderLocation(post, "scale");
}

static void game_frame(void) {
#if defined(__EMSCRIPTEN__)
    if (quit_now) { quit_now = 0; ui = UI_TITLE; web_release(); }
#endif
    float dt = GetFrameTime();
    if (dt > 0.05f) dt = 0.05f;
    g_time += dt;
    ensure_rt();

    update_input(dt);
    if (ui == UI_PLAY && pending_levels > 0 && state == S_PLAY) {
        roll_perks();
        perk_anim = 0;
        ui = UI_PERK;
    }
    if (ui == UI_PLAY && state != S_PLAY) {
        end_delay += dt;
        if (end_delay > 1.3f) ui = UI_END;
    }
    if (ui == UI_PLAY && vend_open && state == S_PLAY) ui = UI_VEND;
    if (ui == UI_PLAY && kitchen_open && state == S_PLAY) ui = UI_KITCHEN;
    if (ui == UI_PERK) perk_anim += dt;
    if (ui != UI_TITLE) update_world(dt);

    nhot = 0;
    hot_hovered = 0;
    BeginDrawing();
    ClearBackground(BLACK);
    if (ui == UI_TITLE) {
        if (!compact_ui()) draw_title();
    } else {
        float texel[2] = {1.0f / vw, 1.0f / vh};
        float cold = clampf((40 - P.soup) / 40.0f, 0, 1) * 0.85f, sc = (float)pscale;
        draw_world();
        SetShaderValue(post, loc_texel, texel, SHADER_UNIFORM_VEC2);
        SetShaderValue(post, loc_hurt, &hurt_flash, SHADER_UNIFORM_FLOAT);
        SetShaderValue(post, loc_cold, &cold, SHADER_UNIFORM_FLOAT);
        SetShaderValue(post, loc_scale, &sc, SHADER_UNIFORM_FLOAT);
        BeginShaderMode(post);
        DrawTexturePro(rt.texture, (Rectangle){0, 0, (float)vw, (float)-vh},
                       (Rectangle){0, 0, (float)(vw * pscale), (float)(vh * pscale)}, (Vector2){0, 0}, 0, WHITE);
        EndShaderMode();
        draw_float_texts();
        draw_stair_signs();
        if (!compact_ui()) draw_hud();
        draw_phase_banner();
        update_draw_uparts(dt);
        if (floor_fade > 0) DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), alphac(BLACK, floor_fade));
        if (compact_ui()) { /* The page presents the matching modal. */ }
        else if (ui == UI_PERK) draw_perk_modal();
        else if (ui == UI_HELP) draw_help();
        else if (ui == UI_CHAR) draw_char();
        else if (ui == UI_VEND) draw_vend_modal();
        else if (ui == UI_KITCHEN) draw_kitchen_modal();
        else if (ui == UI_CONFIRM) draw_confirm();
        else if (ui == UI_END) draw_end();
    }
    if (hot_hovered != cursor_hand) {
        cursor_hand = hot_hovered;
        SetMouseCursor(cursor_hand ? MOUSE_CURSOR_POINTING_HAND : MOUSE_CURSOR_DEFAULT);
    }
    EndDrawing();
}

static void game_shutdown(void) {
    int i;
    if (audio_ok) {
        for (i = 0; i < SFX_N; i++) UnloadSound(sfx[i]);
        CloseAudioDevice();
    }
    for (i = 0; i < SP_N; i++) {
        UnloadTexture(spr[i].tex);
        UnloadTexture(spr[i].white);
        UnloadTexture(spr[i].mask);
    }
    unload_themes();
    UnloadTexture(glow_tex);
    UnloadShader(post);
    if (rt.id) UnloadRenderTexture(rt);
    CloseWindow();
}

int main(int argc, char **argv) {
    if (argc > 1) g_seed = strtoul(argv[1], NULL, 10);
    else g_seed = ((unsigned long)time(NULL) * 2654435761UL ^ (unsigned long)clock()) % 1000000UL;
    vrs ^= (unsigned)g_seed * 2654435761u;
    game_init();
#if defined(__EMSCRIPTEN__)
    emscripten_set_main_loop(game_frame, 0, 1);
#else
    while (!WindowShouldClose() && !quit_now) game_frame();
    game_shutdown();
#endif
    return 0;
}
