BlockCraft add-on: dungeon floors, bosses, pets, levels and loot boxes (rebuild guide) 10OCT2026

A dungeon-crawler mode: go down floor by floor, beat the bosses, level up your hero, earn achievements and open loot boxes with your pet at your side

Posted by on 10th Oct 2026

The third add-on for BlockCraft, my Minecraft-style game for the kids' Fire tablets (after airplanes and helicopters and castles, treasure, potions and armor). This one borrows the game ideas of dungeon-crawler books: a dungeon you go down floor by floor, floor bosses, safe rooms, an announcer who cheers you on, achievements, loot boxes, levels, a class and a pet. Everything has our own names and is made for kids. This post covers what the add-on does and how to add it to an existing BlockCraft. Every block has a Copy button.

What it adds

The dungeon

  • The entrance: a small mossy shrine with a glowing purple stairwell, built once a few steps from the world's start point (in every world, old ones too). Tap the stairwell to go down.
  • Floors: each floor is a maze of 64 rooms joined by dark halls, sealed in unbreakable bedrock. Monsters come out of the dark: zombies and spiders on Floor 1, skeletons from Floor 2, creepers and raptors from Floor 3. Each floor down has more monsters, and they hit 15% harder per floor.
  • Safe rooms: every floor starts in a safe room with a blue floor. Monsters can't come in (any that try vanish with a pop), you heal quickly there, and it has a crafting table, a bed and the green Way Up stairwell back to the shrine.
  • Treasure chests: about one room in three has a golden treasure chest with a loot box, fruit, healing potions, iron, and on deeper floors diamonds and jewels.
  • Floor bosses: the room farthest from the safe room is the boss room (black floor with lights). Walk in and the boss appears, with its name and health bar on screen:
Floor Boss Fights like
1 The Zombie King a huge zombie, 80 health
2 The Spider Queen a giant spider, 100 health
3 The Skeleton Lord a giant skeleton that shoots twice as fast, 110 health
4 The Raptor Alpha a very fast giant raptor, 120 health
5 The Dino Overlord a giant T-Rex, 220 health

From Floor 6 the bosses come round again with 60% more health each time. Beating a boss opens the stairs to the next floor in the middle of the room (for everyone, for good), and everyone who helped gets a Gold Loot Box (Legendary from Floor 3) and lots of XP. The boss comes back for another fight 2 minutes later.

Levels, XP and a class

  • XP comes from monsters (a zombie 5, a T-Rex 30, a boss 150 or more), digging ore (coal 1 up to jewels 6), achievements, loot boxes and reaching a new deepest floor.
  • Levelling up fills your health and gives a point to spend: Strength (+10% damage each), Speed (+6% each, up to 10) or Health (+1 heart each, up to 10, so up to 20 hearts).
  • Level 3 gives everyone a pet (a Kitten Basket, a Puppy Basket or a Baby Dino Egg).
  • A class, picked on the HERO screen and changeable any time:
Class What it does
Warrior hits 50% harder and has 2 extra hearts
Archer arrows do double damage, and half the time the arrow isn't used up
Healer always heals slowly; potions heal double and last twice as long
Explorer runs 25% faster, falls of up to 8 blocks don't hurt, sees better in the dark

Pets

Tap a Kitten Basket, Puppy Basket or Baby Dino Egg and your pet appears: a cat (Whiskers), a dog (Buddy) or a baby T-Rex (Chomper). Rename it on the HERO screen. It follows you everywhere (it jumps to you if it falls behind, and goes down the stairwells with you), fights monsters near you, and levels up as it beats them, hitting harder each level. Pets can't be hurt. Tap your pet and it loves you back. Each kid has one pet at a time; tapping a different basket swaps them and gives the old basket back. A name tag shows whose pet it is.

Achievements and loot boxes

31 achievements, each announced in a big banner by a slightly cheeky announcer ("NEW ACHIEVEMENT: DINO SNACK! Get bitten by a T-Rex. The T-Rex says you taste like chicken."). A few of them: Monster Masher, Zombie Zapper, Kaboom Club, Bigger Teeth, Shiny!, Jewel Thief, Knock Knock, Castle Collector, Up Up and Away, Diamond Hero, Oopsie Daisy, Gravity Wins, Best Friends, Good Pet!, Going Down, Phew!, Floor Boss Down, Deeper and Deeper and Crawler Champion. Each one gives XP and a loot box:

Box Prizes
Bronze apples, torches, arrows, iron, coal, silver, cherries, a healing potion
Silver gold, iron, a diamond or two, a potion, a jewel, a golden apple, fruit
Gold diamonds, jewels, potions, a golden apple, an iron or gold armor piece, a Silver Sword, a gold block
Legendary 8 diamonds, jewels, a diamond armor piece, a castle kit, a pet, strength and shield potions, diamond blocks

Tap a loot box to open it; the announcer reads out what was inside.

The HERO screen

The new HERO button (or H on a keyboard) shows your level and XP bar, your class, points to spend, your pet (with a box to rename it) and every achievement, gold stars for the ones you have. Achievements (and their loot boxes) can't be earned in Creative mode.

How it works

  1. The dungeon is part of the same world, very far to the east (more than a million blocks out, where nobody will ever walk). The world generator builds floors there instead of land: each floor is a 128x128 block maze of 8 by 8 rooms made with a random maze from the world's seed, so every world has its own dungeon and the same seed always makes the same one. Everything around the floors is bedrock, so the only ways in and out are the stairwells.
  2. The stairwells send one message (stairs); the server (or, in single player, the page's own simulation) checks you are standing next to one and moves you to the next floor's safe room, or back to the shrine. Your pet comes along.
  3. Bosses are big versions of normal monsters with more health; the server spawns one when someone walks into a boss room and opens the stairs when it is beaten. Which floors are open is saved in the dungeon part of /opt/blockcraft/data/world.json.
  4. Pets are run by the server like the other animals, but they belong to a player name, follow that player and only attack monsters. They are saved in the pets part of world.json, one per player.
  5. Levels, points, class and achievements live on each tablet with the player's bag, and are saved with it (in the tablet's storage for single player, on the family server for Play Together). The server tells the tablet when you or your pet beat a monster, so it can hand out the XP.

Containers involved

Container Role LAN IP
CT 127 (blockcraft) Node.js game server; gets the new game files x.x.0.193 (DHCP)
CT 108 (Caddy reverse proxy) Unchanged: already routes /blockcraft* to CT 127 x.x.0.122

No new container, port, DNS name or Caddy route is needed. The dungeon needs no extra disk space until someone walks into it, and then only for what they change.

Where to run each command

  • Proxmox host shell: in the Proxmox web UI click pve → Shell (or SSH to the host as root).
  • Everything below is run there.

Add it to an existing BlockCraft

This assumes BlockCraft is already built from the main BlockCraft post, with its files in /root/blockcraft/ on the host and 04-push.sh there. The files below also contain the two earlier add-ons, so this brings any BlockCraft fully up to date. If you are building BlockCraft from scratch, the main post already has these same files, so you can skip this section.

1. Go to the working folder

cd /root/blockcraft

2. Replace the five changed files

Paste each block into the Proxmox host shell. Each one overwrites one file in /root/blockcraft/. The game files are long; let each paste finish before the next.

engine.js: the shared game rules. New: the stairwell blocks, the dungeon floors (maze, rooms, safe rooms, boss rooms, treasure), the entrance shrine, pets, the five bosses, loot boxes and pet items, and the dungeon monsters.

cat > /root/blockcraft/engine.js <<'EOF'
// BlockCraft shared engine: blocks, items, recipes, world generation, physics and the
// game simulation (mobs, arrows, explosions, chests, time of day). The same file runs in the
// browser (single-player runs the simulation in the page) and in Node on CT 127 (multiplayer).
(function (root, factory) {
  if (typeof module === 'object' && module.exports) module.exports = factory();
  else root.BC = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  const CS = 16, CH = 64, SEA = 24, DAY_SECONDS = 600;

  // ---------------------------------------------------------------- textures (drawn by the client)
  const TILES = ('grass_top grass_side dirt stone cobble planks log_side log_top leaves sand gravel water glass ' +
    'coal_ore iron_ore gold_ore diamond_ore bedrock snow snow_side cactus_side cactus_top table_top table_side ' +
    'table_front furnace_front furnace_side furnace_top chest_front chest_side chest_top torch door_bottom door_top ' +
    'bed_top bed_side tnt_side tnt_top tnt_bottom bricks stone_bricks bookshelf flower_red flower_yellow tall_grass ' +
    'wool_white wool_red wool_orange wool_yellow wool_lime wool_blue wool_purple wool_black wool_pink glowstone ' +
    'sandstone_side sandstone_top slab_side slab_top snow_block ladder ' +
    'zombie_face zombie_skin zombie_shirt zombie_pants skeleton_face bone creeper_face creeper_skin spider_face ' +
    'spider_body pig_face pig_skin cow_face cow_skin sheep_face sheep_wool player_face player_hair player_skin ' +
    'player_shirt player_pants arrow_tex tnt_lit diamond_block mossy_bricks trex_skin trex_face raptor_skin raptor_face ' +
    'trike_skin trike_face brachio_skin brachio_face paint0 paint1 paint2 paint3 paint4 paint5 paint6 paint7 plane_wing metal cockpit rotor ' +
    'silver_ore gem_ore gold_block silver_block ice_bricks treasure_front treasure_side treasure_top ' +
    'stairs_down stairs_up stairs_side cat_skin cat_face dog_skin dog_face').split(' ');
  const T = {}; TILES.forEach((n, i) => { T[n] = i; });

  // ---------------------------------------------------------------- blocks
  // shape: cube | cross | torch | door | slab | bed | cactus | water | ladder
  // tex: [top, bottom, side] or [top, bottom, north, south, east, west]
  const BLOCKS = [];
  function B(id, name, o) {
    const d = Object.assign({ id, name, solid: true, opaque: true, shape: 'cube', hard: 1, tool: '', lvl: 0,
      drop: id, cutout: false, light: 0, h: 1, stack: 64, color: '#888' }, o);
    if (d.tex && d.tex.length === 1) d.tex = [d.tex[0], d.tex[0], d.tex[0]];
    BLOCKS[id] = d; return d;
  }
  const t = n => T[n];
  B(0, 'Air', { solid: false, opaque: false, shape: 'none', drop: 0 });
  B(1, 'Grass Block', { tex: [t('grass_top'), t('dirt'), t('grass_side')], hard: 0.6, tool: 'shovel', drop: 2, color: '#5d9c3a' });
  B(2, 'Dirt', { tex: [t('dirt')], hard: 0.5, tool: 'shovel', color: '#86603f' });
  B(3, 'Stone', { tex: [t('stone')], hard: 1.5, tool: 'pick', lvl: 1, drop: 4, color: '#7d7d7d' });
  B(4, 'Cobblestone', { tex: [t('cobble')], hard: 2, tool: 'pick', lvl: 1, color: '#6e6e6e' });
  B(5, 'Planks', { tex: [t('planks')], hard: 2, tool: 'axe', color: '#a8844f' });
  B(6, 'Log', { tex: [t('log_top'), t('log_top'), t('log_side')], hard: 2, tool: 'axe', color: '#6b5130' });
  B(7, 'Leaves', { tex: [t('leaves')], hard: 0.2, opaque: false, cutout: true, drop: -1, color: '#3d7a26' });
  B(8, 'Sand', { tex: [t('sand')], hard: 0.5, tool: 'shovel', color: '#dccf9a' });
  B(9, 'Gravel', { tex: [t('gravel')], hard: 0.6, tool: 'shovel', color: '#8a8380' });
  B(10, 'Water', { tex: [t('water')], solid: false, opaque: false, shape: 'water', hard: -1, drop: 0, color: '#3f6fd8' });
  B(11, 'Glass', { tex: [t('glass')], hard: 0.3, opaque: false, cutout: true, drop: 0, color: '#c8e4ec' });
  B(12, 'Coal Ore', { tex: [t('coal_ore')], hard: 3, tool: 'pick', lvl: 1, drop: 101, color: '#5f5f5f' });
  B(13, 'Iron Ore', { tex: [t('iron_ore')], hard: 3, tool: 'pick', lvl: 2, color: '#8f7d6e' });
  B(14, 'Gold Ore', { tex: [t('gold_ore')], hard: 3, tool: 'pick', lvl: 3, color: '#a39461' });
  B(15, 'Diamond Ore', { tex: [t('diamond_ore')], hard: 3, tool: 'pick', lvl: 3, drop: 104, color: '#6fa4a6' });
  B(16, 'Bedrock', { tex: [t('bedrock')], hard: -1, drop: 0, color: '#333' });
  B(17, 'Snowy Grass', { tex: [t('snow'), t('dirt'), t('snow_side')], hard: 0.6, tool: 'shovel', drop: 2, color: '#f0f4f4' });
  B(18, 'Cactus', { tex: [t('cactus_top'), t('cactus_top'), t('cactus_side')], hard: 0.4, opaque: false, shape: 'cactus', color: '#3f7f2a' });
  B(19, 'Crafting Table', { tex: [t('table_top'), t('planks'), t('table_front'), t('table_side'), t('table_front'), t('table_side')], hard: 2.5, tool: 'axe', color: '#9a6e3e' });
  B(20, 'Furnace', { tex: [t('furnace_top'), t('furnace_top'), t('furnace_front'), t('furnace_side'), t('furnace_side'), t('furnace_side')], hard: 3.5, tool: 'pick', lvl: 1, color: '#5a5a5a' });
  B(21, 'Chest', { tex: [t('chest_top'), t('chest_top'), t('chest_front'), t('chest_side'), t('chest_side'), t('chest_side')], hard: 2.5, tool: 'axe', color: '#a2702d' });
  B(22, 'Torch', { tex: [t('torch')], solid: false, opaque: false, shape: 'torch', hard: 0, light: 1, cutout: true, color: '#ffd84a' });
  // doors: 23-26 closed (X = panel along x, Z = panel along z; bottom/top), 27-30 open
  B(23, 'Door', { tex: [t('door_bottom')], opaque: false, shape: 'door', axis: 'x', half: 0, open: false, hard: 3, tool: 'axe', drop: 115, cutout: true, color: '#8a6234' });
  B(24, 'Door', { tex: [t('door_top')], opaque: false, shape: 'door', axis: 'x', half: 1, open: false, hard: 3, tool: 'axe', drop: 0, cutout: true, color: '#8a6234' });
  B(25, 'Door', { tex: [t('door_bottom')], opaque: false, shape: 'door', axis: 'z', half: 0, open: false, hard: 3, tool: 'axe', drop: 115, cutout: true, color: '#8a6234' });
  B(26, 'Door', { tex: [t('door_top')], opaque: false, shape: 'door', axis: 'z', half: 1, open: false, hard: 3, tool: 'axe', drop: 0, cutout: true, color: '#8a6234' });
  B(27, 'Door', { tex: [t('door_bottom')], solid: false, opaque: false, shape: 'door', axis: 'x', half: 0, open: true, hard: 3, tool: 'axe', drop: 115, cutout: true, color: '#8a6234' });
  B(28, 'Door', { tex: [t('door_top')], solid: false, opaque: false, shape: 'door', axis: 'x', half: 1, open: true, hard: 3, tool: 'axe', drop: 0, cutout: true, color: '#8a6234' });
  B(29, 'Door', { tex: [t('door_bottom')], solid: false, opaque: false, shape: 'door', axis: 'z', half: 0, open: true, hard: 3, tool: 'axe', drop: 115, cutout: true, color: '#8a6234' });
  B(30, 'Door', { tex: [t('door_top')], solid: false, opaque: false, shape: 'door', axis: 'z', half: 1, open: true, hard: 3, tool: 'axe', drop: 0, cutout: true, color: '#8a6234' });
  B(31, 'Bed', { tex: [t('bed_top'), t('planks'), t('bed_side')], opaque: false, shape: 'bed', h: 0.5625, hard: 0.2, color: '#c43a3a' });
  B(32, 'TNT', { tex: [t('tnt_top'), t('tnt_bottom'), t('tnt_side')], hard: 0, color: '#d44030' });
  B(33, 'Bricks', { tex: [t('bricks')], hard: 2, tool: 'pick', lvl: 1, color: '#975a49' });
  B(34, 'Stone Bricks', { tex: [t('stone_bricks')], hard: 1.5, tool: 'pick', lvl: 1, color: '#7a7a7a' });
  B(35, 'Bookshelf', { tex: [t('planks'), t('planks'), t('bookshelf')], hard: 1.5, tool: 'axe', color: '#7d5a3a' });
  B(36, 'Red Flower', { tex: [t('flower_red')], solid: false, opaque: false, shape: 'cross', hard: 0, cutout: true, color: '#d22' });
  B(37, 'Yellow Flower', { tex: [t('flower_yellow')], solid: false, opaque: false, shape: 'cross', hard: 0, cutout: true, color: '#ee2' });
  B(38, 'Tall Grass', { tex: [t('tall_grass')], solid: false, opaque: false, shape: 'cross', hard: 0, cutout: true, drop: 0, color: '#5d9c3a' });
  const WOOL = [['White', 'wool_white', '#e8e8e8'], ['Red', 'wool_red', '#b02e26'], ['Orange', 'wool_orange', '#f07d19'],
    ['Yellow', 'wool_yellow', '#f8c627'], ['Lime', 'wool_lime', '#70b919'], ['Blue', 'wool_blue', '#3c44aa'],
    ['Purple', 'wool_purple', '#8932b8'], ['Black', 'wool_black', '#1d1d21'], ['Pink', 'wool_pink', '#f38baa']];
  WOOL.forEach((w, i) => B(39 + i, w[0] + ' Wool', { tex: [t(w[1])], hard: 0.8, color: w[2] }));
  B(48, 'Stone Slab', { tex: [t('slab_top'), t('slab_top'), t('slab_side')], opaque: false, shape: 'slab', h: 0.5, hard: 2, tool: 'pick', lvl: 1, color: '#a0a0a0' });
  B(49, 'Glowstone', { tex: [t('glowstone')], hard: 0.3, light: 1, color: '#f4d27a' });
  B(50, 'Sandstone', { tex: [t('sandstone_top'), t('sandstone_top'), t('sandstone_side')], hard: 0.8, tool: 'pick', lvl: 1, color: '#d8c98e' });
  B(51, 'Snow Block', { tex: [t('snow_block')], hard: 0.2, tool: 'shovel', color: '#f6fafa' });
  B(52, 'Ladder', { tex: [t('ladder')], solid: false, opaque: false, shape: 'ladder', hard: 0.4, tool: 'axe', cutout: true, color: '#9a7a48' });
  B(53, 'Diamond Block', { tex: [t('diamond_block')], hard: 5, tool: 'pick', lvl: 3, color: '#5decf5' });
  B(54, 'Mossy Stone Bricks', { tex: [t('mossy_bricks')], hard: 1.5, tool: 'pick', lvl: 1, color: '#6f7a62' });
  B(55, 'Silver Ore', { tex: [t('silver_ore')], hard: 3, tool: 'pick', lvl: 2, color: '#9aa4ae' });
  B(56, 'Jewel Ore', { tex: [t('gem_ore')], hard: 3, tool: 'pick', lvl: 3, drop: -2, color: '#8a6f9a' }); // drops a random jewel
  B(57, 'Gold Block', { tex: [t('gold_block')], hard: 3, tool: 'pick', lvl: 2, color: '#f5d742' });
  B(58, 'Silver Block', { tex: [t('silver_block')], hard: 3, tool: 'pick', lvl: 2, color: '#d8dee6' });
  B(59, 'Ice Bricks', { tex: [t('ice_bricks')], hard: 1.5, tool: 'pick', lvl: 1, color: '#a8d8f0' });
  B(60, 'Treasure Chest', { tex: [t('treasure_top'), t('treasure_top'), t('treasure_front'), t('treasure_side'), t('treasure_side'), t('treasure_side')], hard: 2.5, tool: 'axe', drop: 21, color: '#c89a2d' });
  const isChest = id => id === 21 || id === 60;
  // dungeon stairwells: tap to go down a floor / back up to the entrance. Unbreakable and they glow.
  B(61, 'Dungeon Stairs', { tex: [t('stairs_down'), t('stairs_side'), t('stairs_side')], hard: -1, drop: 0, light: 1, color: '#8a40d0' });
  B(62, 'Way Up', { tex: [t('stairs_up'), t('stairs_side'), t('stairs_side')], hard: -1, drop: 0, light: 1, color: '#40d0a0' });
  const NBLOCKS = BLOCKS.length;
  const blk = id => BLOCKS[id] || BLOCKS[0];
  const isOpaque = new Uint8Array(256), isSolid = new Uint8Array(256);
  for (let i = 0; i < NBLOCKS; i++) { isOpaque[i] = BLOCKS[i].opaque ? 1 : 0; isSolid[i] = BLOCKS[i].solid ? 1 : 0; }

  // ---------------------------------------------------------------- items (ids 100+)
  const ITEMS = {};
  function I(id, name, o) { ITEMS[id] = Object.assign({ id, name, stack: 64 }, o); }
  I(100, 'Stick', {}); I(101, 'Coal', { fuel: 8 }); I(102, 'Iron Ingot', {}); I(103, 'Gold Ingot', {});
  I(104, 'Diamond', {}); I(105, 'Apple', { food: 4 }); I(106, 'Raw Porkchop', { food: 3 }); I(107, 'Cooked Porkchop', { food: 8 });
  I(108, 'Raw Beef', { food: 3 }); I(109, 'Steak', { food: 8 }); I(110, 'Rotten Flesh', { food: 4 }); I(111, 'Bone', {});
  I(112, 'Arrow', {}); I(113, 'Gunpowder', {}); I(114, 'Leather', {}); I(115, 'Door', { places: 23 }); I(116, 'String', {});
  I(117, 'Bow', { stack: 1, bow: true, dur: 384 });
  const MATS = [['Wooden', 1, 60, 2], ['Stone', 2, 132, 4], ['Iron', 3, 251, 6], ['Diamond', 4, 1562, 8]];
  const KINDS = [['Pickaxe', 'pick'], ['Axe', 'axe'], ['Shovel', 'shovel'], ['Sword', 'sword']];
  MATS.forEach((m, mi) => KINDS.forEach((k, ki) => I(120 + mi * 4 + ki, m[0] + ' ' + k[0],
    { stack: 1, tool: k[1], tier: m[1], dur: m[2], speed: m[3], dmg: k[1] === 'sword' ? 3 + mi * 1.5 : 2 + mi })));
  I(140, 'Raw Dino Meat', { food: 3 }); I(141, 'Cooked Dino Steak', { food: 10 }); I(142, 'T-Rex Tooth', {});
  I(143, 'T-Rex Tooth Sword', { stack: 1, tool: 'sword', tier: 4, dur: 900, speed: 8, dmg: 10 });
  I(144, 'Airplane', { stack: 1, vehicle: 'plane', earn: true }); I(145, 'Helicopter', { stack: 1, vehicle: 'heli', earn: true });
  // treasure add-on: silver, jewels, fruit, potions, armor, castle kits. earn: kept out of the Creative list
  I(146, 'Silver Ingot', {});
  I(147, 'Ruby', { earn: true }); I(148, 'Emerald', { earn: true }); I(149, 'Sapphire', { earn: true }); I(150, 'Amethyst', { earn: true });
  I(151, 'Golden Apple', { food: 10, heal: 6, fx: 'regen', secs: 10, earn: true });
  I(152, 'Pear', { food: 4 }); I(153, 'Orange', { food: 4 }); I(154, 'Banana', { food: 5 }); I(155, 'Cherries', { food: 3 });
  I(156, 'Grapes', { food: 3 }); I(157, 'Watermelon Slice', { food: 3 });
  I(159, 'Glass Bottle', {});
  const POTIONS = [[160, 'Healing Potion', 'heal', 0, '#f03a5a'], [161, 'Regeneration Potion', 'regen', 30, '#f088c8'],
    [162, 'Strength Potion', 'str', 90, '#b02020'], [163, 'Speed Potion', 'speed', 90, '#7ad0f0'], [164, 'Jump Potion', 'jump', 90, '#60e060'],
    [165, 'Shield Potion', 'shield', 90, '#9a7ad8'], [166, 'Night Vision Potion', 'night', 120, '#3050d0'], [167, 'Water Breathing Potion', 'water', 120, '#30a0b0']];
  POTIONS.forEach(([id, name, fx, secs, color]) => I(id, name, { stack: 16, potion: fx, secs, color, earn: true }));
  // armor: 170 + material * 4 + piece. pts = how much it protects (all four diamond pieces stop 80% of monster damage)
  const ARMOR_MATS = [['Iron', 102, [2, 6, 5, 2]], ['Gold', 103, [2, 5, 3, 1]], ['Diamond', 104, [3, 8, 6, 3]]];
  const ARMOR_PIECES = [['Helmet', 5], ['Chestplate', 8], ['Leggings', 7], ['Boots', 4]];
  ARMOR_MATS.forEach(([mat, , pts], mi) => ARMOR_PIECES.forEach(([piece], pi) => I(170 + mi * 4 + pi, mat + ' ' + piece,
    { stack: 1, armor: pi, pts: pts[pi], tier: mi, earn: mi === 2 })));
  I(182, 'Silver Sword', { stack: 1, tool: 'sword', tier: 3, dur: 400, speed: 6, dmg: 7, undead: 2 }); // double damage to zombies and skeletons
  // castle kits: place one and the whole castle builds itself, owned by whoever placed it
  const KITS = [[183, 'fort'], [184, 'stone'], [185, 'royal'], [186, 'palace'], [187, 'ice']];
  // castle styles. wall: [main, other] blocks mixed by `mix`; found: foundation; walk: walkway along the walls; roof: keep roof;
  // flag: wool colour; rich: blocks only natural castles get (a kit castle must not be worth more than the kit)
  const CASTLES = {
    fort: { name: 'Wooden Fort', wall: [6, 5], mix: 0.3, found: 4, floor: 5, walk: 5, roof: 5, flag: 43 },
    stone: { name: 'Stone Castle', wall: [34, 54], mix: 0.22, found: 34, floor: 4, walk: 48, roof: 48, flag: 40 },
    royal: { name: 'Royal Castle', wall: [34, 34], mix: 0, found: 34, floor: 34, walk: 48, roof: 48, flag: 45, rich: 57 },
    palace: { name: 'Desert Palace', wall: [50, 50], mix: 0, found: 50, floor: 50, walk: 48, roof: 50, flag: 41, rich: 57 },
    ice: { name: 'Ice Castle', wall: [59, 51], mix: 0.25, found: 59, floor: 51, walk: 48, roof: 59, flag: 44, rich: 58 },
  };
  KITS.forEach(([id, type]) => I(id, CASTLES[type].name + ' Kit', { stack: 1, kit: type, earn: true }));
  // dungeon add-on: loot boxes (tap to open) and pet items (tap to call your pet)
  const BOXES = [[190, 'Bronze', '#c87a3a'], [191, 'Silver', '#d8dee6'], [192, 'Gold', '#f5d742'], [193, 'Legendary', '#c060ff']];
  BOXES.forEach(([id, tier, color], ti) => I(id, tier + ' Loot Box', { stack: 16, box: ti, color, earn: true }));
  I(194, 'Kitten Basket', { stack: 1, pet: 'cat', earn: true }); I(195, 'Puppy Basket', { stack: 1, pet: 'dog', earn: true });
  I(196, 'Baby Dino Egg', { stack: 1, pet: 'babydino', earn: true });
  const itemName = id => id < 100 ? blk(id).name : (ITEMS[id] ? ITEMS[id].name : '?');
  const stackSize = id => id < 100 ? 64 : (ITEMS[id] ? ITEMS[id].stack : 64);
  const isItem = id => id >= 100;
  const allCreative = [];
  for (let i = 1; i < NBLOCKS; i++) if (i !== 10 && i !== 16 && i < 60 && !(i >= 23 && i <= 30)) allCreative.push(i);
  Object.keys(ITEMS).forEach(k => { if (!ITEMS[k].earn) allCreative.push(+k); }); // vehicles, jewels, potions, kits... have to be earned in survival

  // ---------------------------------------------------------------- recipes
  // st: '' anywhere, 'table' near a crafting table, 'furnace' near a furnace (uses fuel)
  const R = (out, n, inp, st) => ({ out, n, inp, st: st || '' });
  const RECIPES = [
    R(5, 4, [[6, 1]]), R(100, 4, [[5, 2]]), R(19, 1, [[5, 4]]), R(22, 4, [[100, 1], [101, 1]]),
    R(20, 1, [[4, 8]], 'table'), R(21, 1, [[5, 8]], 'table'), R(115, 1, [[5, 6]], 'table'),
    R(31, 1, [[39, 3], [5, 3]], 'table'), R(32, 1, [[113, 5], [8, 4]], 'table'), R(52, 3, [[100, 7]], 'table'),
    R(34, 4, [[3, 4]], 'table'), R(48, 6, [[4, 3]], 'table'), R(50, 1, [[8, 4]], 'table'), R(35, 1, [[5, 6], [114, 3]], 'table'),
    R(117, 1, [[100, 3], [116, 3]], 'table'), R(112, 4, [[100, 1], [9, 1]], 'table'), R(39, 1, [[116, 4]], 'table'),
    R(49, 1, [[103, 1], [101, 4]], 'table'), R(33, 1, [[2, 2], [8, 2]], 'furnace'),
    R(102, 1, [[13, 1]], 'furnace'), R(103, 1, [[14, 1]], 'furnace'), R(11, 1, [[8, 1]], 'furnace'),
    R(3, 1, [[4, 1]], 'furnace'), R(107, 1, [[106, 1]], 'furnace'), R(109, 1, [[108, 1]], 'furnace'), R(101, 1, [[6, 1]], 'furnace'),
    R(141, 1, [[140, 1]], 'furnace'), R(53, 1, [[104, 9]], 'table'), R(104, 9, [[53, 1]]), R(143, 1, [[142, 2], [100, 1]], 'table'),
    R(54, 1, [[34, 1], [7, 1]], 'table'),
    // vehicles are the big goal: lots of iron plus diamonds
    R(144, 1, [[102, 8], [104, 2], [5, 4], [11, 1]], 'table'), R(145, 1, [[102, 10], [104, 4], [11, 2]], 'table'),
    // treasure add-on
    R(146, 1, [[55, 1]], 'furnace'), R(57, 1, [[103, 9]], 'table'), R(103, 9, [[57, 1]]), R(58, 1, [[146, 9]], 'table'), R(146, 9, [[58, 1]]),
    R(59, 4, [[11, 4], [146, 1]], 'table'), R(159, 3, [[11, 1]], 'table'), R(151, 1, [[105, 1], [103, 8]], 'table'),
    R(160, 1, [[159, 1], [105, 2]], 'table'), R(161, 1, [[159, 1], [151, 1]], 'table'), R(162, 1, [[159, 1], [147, 1]], 'table'),
    R(163, 1, [[159, 1], [149, 1]], 'table'), R(164, 1, [[159, 1], [148, 1]], 'table'), R(165, 1, [[159, 1], [150, 1], [102, 1]], 'table'),
    R(166, 1, [[159, 1], [49, 1]], 'table'), R(167, 1, [[159, 1], [156, 2]], 'table'),
    R(182, 1, [[146, 2], [100, 1]], 'table'),
    R(183, 1, [[6, 40], [11, 4], [22, 8]], 'table'), R(184, 1, [[34, 64], [11, 16], [102, 4]], 'table'),
    R(185, 1, [[34, 64], [57, 8], [53, 2], [147, 4]], 'table'), R(186, 1, [[50, 64], [57, 4], [11, 8]], 'table'),
    R(187, 1, [[11, 64], [149, 4], [146, 16]], 'table'),
  ];
  ARMOR_MATS.forEach(([, m], mi) => ARMOR_PIECES.forEach(([, n], pi) => RECIPES.push(R(170 + mi * 4 + pi, 1, [[m, n]], 'table'))));
  const toolMat = [5, 4, 102, 104];
  toolMat.forEach((m, mi) => {
    RECIPES.push(R(120 + mi * 4, 1, [[m, 3], [100, 2]], 'table'), R(121 + mi * 4, 1, [[m, 3], [100, 2]], 'table'),
      R(122 + mi * 4, 1, [[m, 1], [100, 2]], 'table'), R(123 + mi * 4, 1, [[m, 2], [100, 1]], 'table'));
  });
  // dyed wool: white wool plus something colourful
  [[40, [36]], [41, [36, 37]], [42, [37]], [43, [18]], [44, [11, 9]], [45, [36, 11]], [46, [101]], [47, [36, 111]]]
    .forEach(([out, dyes]) => RECIPES.push(R(out, 1, [[39, 1]].concat(dyes.map(d => [d, 1])))));
  const FUEL = { 101: 8, 5: 1.5, 6: 1.5, 100: 0.5, 19: 1.5, 21: 1.5 };

  // ---------------------------------------------------------------- noise
  function hash(x, y, z, s) {
    let h = Math.imul(x | 0, 0x27d4eb2d) ^ Math.imul(y | 0, 0x165667b1) ^ Math.imul(z | 0, 0x1b873593) ^ Math.imul(s | 0, 0x85ebca6b);
    h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d);
    h = Math.imul(h ^ (h >>> 12), 0x297a2d39);
    h ^= h >>> 15;
    return (h >>> 0) / 4294967296;
  }
  const sm = f => f * f * (3 - 2 * f);
  function noise2(x, z, s) {
    const xi = Math.floor(x), zi = Math.floor(z), fx = sm(x - xi), fz = sm(z - zi);
    const a = hash(xi, 0, zi, s), b = hash(xi + 1, 0, zi, s), c = hash(xi, 0, zi + 1, s), d = hash(xi + 1, 0, zi + 1, s);
    return a + (b - a) * fx + (c - a) * fz + (a - b - c + d) * fx * fz;
  }
  function fbm2(x, z, s, oct) {
    let v = 0, amp = 1, tot = 0, f = 1;
    for (let o = 0; o < oct; o++) { v += noise2(x * f, z * f, s + o * 31) * amp; tot += amp; amp *= 0.5; f *= 2; }
    return v / tot;
  }
  function noise3(x, y, z, s) {
    const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
    const fx = sm(x - xi), fy = sm(y - yi), fz = sm(z - zi);
    const l = (a, b, f) => a + (b - a) * f;
    const c = (dx, dy, dz) => hash(xi + dx, yi + dy, zi + dz, s);
    return l(l(l(c(0, 0, 0), c(1, 0, 0), fx), l(c(0, 1, 0), c(1, 1, 0), fx), fy),
      l(l(c(0, 0, 1), c(1, 0, 1), fx), l(c(0, 1, 1), c(1, 1, 1), fx), fy), fz);
  }

  // ---------------------------------------------------------------- world generation
  // biome: 0 plains, 1 forest, 2 desert, 3 snowy
  function biomeAt(x, z, seed) {
    const temp = fbm2(x / 220, z / 220, seed + 101, 2), wet = fbm2(x / 170, z / 170, seed + 202, 2);
    if (temp > 0.62) return 2;
    if (temp < 0.36) return 3;
    return wet > 0.52 ? 1 : 0;
  }
  function heightAt(x, z, seed) {
    const cont = fbm2(x / 110, z / 110, seed, 3), hill = fbm2(x / 36, z / 36, seed + 50, 3);
    let h = 14 + cont * 20 + Math.pow(hill, 2) * 16 + (Math.max(0, cont - 0.62) * 40);
    return Math.max(3, Math.min(CH - 10, Math.floor(h)));
  }
  function treeAt(x, z, seed, biome, h) {
    if (h <= SEA || biome === 2) return 0;
    const r = hash(x, 7, z, seed);
    const dens = biome === 1 ? 0.035 : biome === 3 ? 0.012 : 0.004;
    if (r >= dens) return 0;
    return 4 + Math.floor(hash(x, 8, z, seed) * 3);
  }

  // ---------------------------------------------------------------- castles
  // At most one castle per 128x128 region, 21x21 blocks, built on the ground level of its centre. The type depends on
  // the land and luck: grassland/forest has Stone Castles (the original ones, unchanged), Wooden Forts and rare Royal
  // Castles; deserts have Desert Palaces; snowy land has Ice Castles. Castle kits build the same castles (c.kit = true).
  const REG = 128, CW = 21;
  function castleAt(rx, rz, seed) {
    const h = hash(rx, 99, rz, seed);
    if (h >= 0.7) return null;
    const cx = rx * REG + 24 + Math.floor(hash(rx, 98, rz, seed) * (REG - 48)), cz = rz * REG + 24 + Math.floor(hash(rx, 97, rz, seed) * (REG - 48));
    const bm = biomeAt(cx, cz, seed), base = heightAt(cx, cz, seed);
    if (base <= SEA + 1 || base > CH - 18) return null;
    if (Math.abs(cx) < 30 && Math.abs(cz) < 30) return null; // keep the world spawn area free
    const type = bm <= 1 ? (h < 0.45 ? 'stone' : h < 0.62 ? 'fort' : 'royal') : h < 0.5 ? (bm === 2 ? 'palace' : 'ice') : null;
    if (!type) return null;
    return { x0: cx - 10, z0: cz - 10, base, seed, type };
  }
  // block id for castle-local (lx, y, lz), or -1 to leave the land alone
  function castleBlock(c, lx, y, lz) {
    const b = c.base, edge = lx === 0 || lx === CW - 1 || lz === 0 || lz === CW - 1, P = CASTLES[c.type || 'stone'];
    const wall = () => P.mix && hash(c.x0 + lx, y, c.z0 + lz, c.seed + 5) < P.mix ? P.wall[1] : P.wall[0];
    const top = () => P.rich && !c.kit ? P.rich : wall(); // battlements: gold or silver on rich natural castles
    if (y < b - 6) return -1;
    if (y <= b) return y === b ? (edge ? P.found : P.floor) : P.found;  // foundation and courtyard floor
    if (y > b + 16) return -1;
    // corner towers: 5x5 rings, 8 high, ladder inside, floor and battlements on top, a flag
    const tx = lx < 5 ? 0 : lx >= CW - 5 ? CW - 5 : -1, tz = lz < 5 ? 0 : lz >= CW - 5 ? CW - 5 : -1;
    if (tx >= 0 && tz >= 0) {
      const ix = lx - tx, iz = lz - tz, ring = ix === 0 || ix === 4 || iz === 0 || iz === 4;
      const inX = tx === 0 ? 4 : 0, inZ = tz === 0 ? 4 : 0; // the ring sides facing the courtyard
      if (ring) {
        if (y <= b + 8) {
          if (y <= b + 2 && ix === 2 && iz === inZ) return 0;          // doorway from the courtyard
          if (y === b + 4 && (ix === 2 || iz === 2) && !(iz === inZ || ix === inX)) return 11; // arrow slits
          return wall();
        }
        if (y === b + 9) return (ix + iz) % 2 === 0 ? top() : 0;
        return 0;
      }
      if (ix === 2 && iz === (inZ === 4 ? 1 : 3)) return y <= b + 8 ? 52 : 0;  // ladder beside the far wall
      if (y === b + 8) return 5;
      if (ix === 2 && iz === 2 && (y === b + 9 || y === b + 10)) return 6;     // flag pole
      if (ix === 3 && iz === 2 && y === b + 10) return c.flag || P.flag;        // flag (a kit castle flies its owner's colour)
      if (y === b + 2 && ix === 1 && iz === 2) return 22;
      return 0;
    }
    // outer walls with a gate in the north wall and battlements
    if (edge) {
      if (lz === 0 && lx >= 9 && lx <= 11 && y <= b + 3) return 0;
      if (y <= b + 5) return wall();
      if (y === b + 6) return (lx + lz) % 2 === 0 ? top() : 0;
      return 0;
    }
    // walkway along the inside of the walls
    if ((lx === 1 || lx === CW - 2 || lz === 1 || lz === CW - 2) && y === b + 5 && !(lz === 1 && lx >= 8 && lx <= 12)) return P.walk;
    // the keep: 7x7 house in the middle with a door, windows, a slab roof, chest, table and torches
    if (lx >= 7 && lx <= 13 && lz >= 9 && lz <= 15) {
      const kx = lx - 7, kz = lz - 9, kwall = kx === 0 || kx === 6 || kz === 0 || kz === 6;
      if (kwall) {
        if (kz === 0 && kx === 3 && y === b + 1) return 23;
        if (kz === 0 && kx === 3 && y === b + 2) return 24;
        if (y <= b + 6) return (y === b + 3 && (kx === 3 || kz === 3) && !(kz === 0 && kx === 3)) ? 11 : wall();
        if (y === b + 7) return (kx + kz) % 2 === 0 ? wall() : 0;
        return 0;
      }
      if (y === b + 7) return P.roof;
      if (y === b + 1 && kx === 3 && kz === 5) return 60;                        // the treasure chest
      if (y === b + 1 && kx === 1 && kz === 5) return 19;
      if (y === b + 1 && kx === 5 && kz === 5) return 31;
      if (y === b + 3 && (kx === 1 || kx === 5) && kz === 1) return 22;
      return 0;
    }
    if (y === b + 1 && (lx === 6 || lx === 14) && lz === 4) return 22;          // courtyard torches
    return 0;
  }
  function castleLoot(x, y, z, seed) {
    const c = castleAt(Math.floor(x / REG), Math.floor((z - 4) / REG), seed); // the chest sits at the castle centre + 4 south
    if (!c || x !== c.x0 + 10 || z !== c.z0 + 14 || y !== c.base + 1) return null;
    // each castle type has its own treasure; rarer castles hold better things
    const n = (k, a, b) => a + Math.floor(hash(x, y + k, z, seed + 61) * (b - a + 1));
    const luck = (k, p) => hash(x, y + k, z, seed + 62) < p;
    const s = [], add = (id, cnt, d) => { if (cnt > 0) s.push(d ? { id, n: cnt, d } : { id, n: cnt }); };
    const fruit = [105, 152, 153, 154, 155, 156, 157], jewel = [147, 148, 149, 150];
    switch (c.type) {
      case 'fort': // simple camp supplies
        add(105, n(1, 3, 6)); add(fruit[n(2, 1, 6)], n(3, 2, 4)); add(102, n(4, 2, 5)); add(22, 12); add(112, n(5, 6, 14));
        add(160, 1); if (luck(1, 0.5)) add(173, 1); if (luck(2, 0.3)) add(146, n(6, 2, 4)); break;
      case 'palace': // gold, silver, jewels, desert fruit
        add(103, n(1, 6, 12)); add(146, n(2, 4, 8)); add(147, n(3, 1, 3)); add(148, n(4, 1, 3)); add(153, n(5, 2, 5));
        add(154, n(6, 2, 5)); add(156, n(7, 2, 4)); add(163, 1); add(160, n(8, 1, 2));
        if (luck(1, 0.35)) add(151, 1); add(174 + n(9, 0, 3), 1); break;
      case 'ice': // diamonds, cold jewels, silver
        add(104, n(1, 4, 8)); add(149, n(2, 2, 4)); add(150, n(3, 1, 3)); add(146, n(4, 4, 8)); add(157, n(5, 2, 4));
        add(167, 1); add(166, 1); add(160, 1); if (luck(1, 0.5)) add(181, 1); if (luck(2, 0.3)) add(53, 1); break;
      case 'royal': // the best: lots of diamonds, every jewel, strong potions, diamond tools and armor
        add(104, n(1, 8, 14)); jewel.forEach((j, i) => add(j, n(10 + i, 2, 4))); add(151, n(2, 1, 2)); add(103, n(3, 4, 8));
        add(162, 1); add(165, 1); add(161, 1); add(160, 2); add(luck(1, 0.5) ? 132 : 135, 1, 1562);
        add(178 + n(4, 0, 3), 1); add(53, 1); break;
      default: // the original Stone Castle chest, plus a little silver and a healing potion
        add(104, n(1, 3, 6)); add(102, n(2, 4, 9)); add(103, n(3, 2, 5)); add(105, n(4, 2, 5)); add(22, 16); add(112, n(5, 4, 12));
        if (hash(x, y, z, seed + 62) < 0.4) add(135, 1, 1562);
        if (hash(x, y, z, seed + 63) < 0.5) add(53, 1);
        add(146, n(6, 2, 5)); add(160, 1);
    }
    return s;
  }

  // ---------------------------------------------------------------- dungeon floors
  // The dungeon is far away to the east (x from DGX), sealed in bedrock. Floor f (1, 2, ...) is a 128x128 maze of 8x8 cells,
  // one room per cell, joined by corridors. Cell (0,0) is the safe room with the Way Up; the cell farthest from it is the
  // boss room, where the stairs to the next floor appear once its boss has been beaten.
  const DGX = 1048576, FSTEP = 512, FW = 128, FY = 10, NC = 8;
  const inDungeon = x => x >= DGX - 256;
  const floorAt = x => inDungeon(x) ? Math.floor((x - DGX) / FSTEP) + 1 : 0;
  const mazes = new Map();
  function dungeonMaze(f, seed) {
    const k = f + ':' + seed; let mz = mazes.get(k);
    if (mz) return mz;
    const open = new Uint8Array(NC * NC); // bits: 1 = +x, 2 = -x, 4 = +z, 8 = -z
    const seen = new Uint8Array(NC * NC), stack = [0]; seen[0] = 1; let step = 0;
    const DIRS = [[1, 0, 1, 2], [-1, 0, 2, 1], [0, 1, 4, 8], [0, -1, 8, 4]];
    while (stack.length) { // random depth-first maze, then a few extra links so it isn't one long path
      const c = stack[stack.length - 1], cx = c % NC, cz = (c / NC) | 0;
      const opts = DIRS.filter(([dx, dz]) => { const x = cx + dx, z = cz + dz; return x >= 0 && z >= 0 && x < NC && z < NC && !seen[z * NC + x]; });
      if (!opts.length) { stack.pop(); continue; }
      const [dx, dz, a, b] = opts[Math.floor(hash(f, step++, c, seed + 71) * opts.length)], n = (cz + dz) * NC + cx + dx;
      open[c] |= a; open[n] |= b; seen[n] = 1; stack.push(n);
    }
    for (let c = 0; c < NC * NC; c++) if (hash(f, c, 5, seed + 72) < 0.12 && c % NC < NC - 1) { open[c] |= 1; open[c + 1] |= 2; }
    const dist = new Int16Array(NC * NC).fill(-1), q = [0]; dist[0] = 0; let boss = 0;
    while (q.length) {
      const c = q.shift();
      if (dist[c] > dist[boss]) boss = c;
      for (const [dx, dz, a] of DIRS) if (open[c] & a) { const n = c + dz * NC + dx; if (dist[n] < 0) { dist[n] = dist[c] + 1; q.push(n); } }
    }
    const rooms = [];
    for (let c = 0; c < NC * NC; c++) {
      const big = c === 0 || c === boss, w = big ? 12 : 6 + Math.floor(hash(f, c, 1, seed + 73) * 5), d = big ? 12 : 6 + Math.floor(hash(f, c, 2, seed + 73) * 5);
      const cx = (c % NC) * 16 + 8, cz = ((c / NC) | 0) * 16 + 8;
      rooms.push({ x0: cx - (w >> 1), z0: cz - (d >> 1), x1: cx - (w >> 1) + w - 1, z1: cz - (d >> 1) + d - 1, cx, cz,
        chest: !big && hash(f, c, 3, seed + 74) < 0.3 });
    }
    mz = { open, boss, rooms, ox: DGX + (f - 1) * FSTEP };
    mazes.set(k, mz); if (mazes.size > 12) mazes.delete(mazes.keys().next().value);
    return mz;
  }
  // what to stand on / where things are, in world coordinates
  const floorStart = (f, seed) => { const mz = dungeonMaze(f, seed), r = mz.rooms[0]; return { x: mz.ox + r.cx + 0.5, y: FY + 1, z: r.cz + 2.5 }; };
  const bossRoom = (f, seed) => { const mz = dungeonMaze(f, seed), r = mz.rooms[mz.boss]; return { x0: mz.ox + r.x0, z0: r.z0, x1: mz.ox + r.x1, z1: r.z1, cx: mz.ox + r.cx, cz: r.cz }; };
  function inSafeRoom(x, z, seed) {
    const f = floorAt(x); if (f < 1) return false;
    const r = dungeonMaze(f, seed).rooms[0], lx = x - (DGX + (f - 1) * FSTEP);
    return lx >= r.x0 - 0.3 && lx <= r.x1 + 1.3 && z >= r.z0 - 0.3 && z <= r.z1 + 1.3;
  }
  // block for dungeon world position (x, y, z)
  function dungeonBlock(x, y, z, seed) {
    if (y === 0 || y > FY + 8) return 16;
    const f = floorAt(x), lx = x - (DGX + (f - 1) * FSTEP);
    if (f < 1 || lx < 0 || lx >= FW || z < 0 || z >= FW) return 16;
    const mz = dungeonMaze(f, seed), c = (z >> 4) * NC + (lx >> 4), r = mz.rooms[c], o = mz.open[c];
    const inRoom = lx >= r.x0 && lx <= r.x1 && z >= r.z0 && z <= r.z1;
    const lcx = lx & 15, lcz = z & 15;
    const inHall = (lcz >= 7 && lcz <= 9 && ((o & 1 && lcx >= 8) || (o & 2 && lcx <= 8))) || (lcx >= 7 && lcx <= 9 && ((o & 4 && lcz >= 8) || (o & 8 && lcz <= 8)));
    const brick = hash(x, y, z, seed + 75) < 0.25 ? 54 : 34;
    if (y < FY) return brick;
    if (y === FY) return inRoom && c === 0 ? 44 : inRoom && c === mz.boss ? ((lx - r.x0) % 4 === 1 && (z - r.z0) % 4 === 1 ? 49 : 46) : brick; // safe room: blue floor; boss room: black with lights
    if (y === FY + 6) return inRoom && lx === r.cx && z === r.cz ? 49 : brick;           // a light in every room's ceiling
    if (y > FY + 6) return brick;
    if (!inRoom && !inHall) return brick;
    if (inRoom && c === 0) {                                                              // the safe room
      if (y === FY + 1 && lx === r.cx && z === r.cz) return 62;
      if (y === FY + 1 && lx === r.x0 && z === r.z0) return 19;
      if (y === FY + 1 && lx === r.x1 && z === r.z0) return 31;
      if (y === FY + 3 && (lx === r.x0 || lx === r.x1) && z === r.cz) return 22;
    }
    if (inRoom && r.chest && y === FY + 1 && lx === r.x0 && z === r.z0) return 60;
    if (inRoom && c !== 0 && c !== mz.boss && y === FY + 1 && lx === r.x1 && z === r.z1 && hash(x, y, z, seed + 76) < 0.5) return 35;
    return 0;
  }
  function dungeonLoot(x, y, z, seed) {
    const f = floorAt(x); if (f < 1 || y !== FY + 1) return null;
    const mz = dungeonMaze(f, seed), lx = x - mz.ox, c = (z >> 4) * NC + (lx >> 4), r = mz.rooms[c];
    if (!r || !r.chest || lx !== r.x0 || z !== r.z0) return null;
    const n = (k, a, b) => a + Math.floor(hash(x, y + k, z, seed + 77) * (b - a + 1)), s = [];
    s.push({ id: 190 + Math.min(3, Math.floor((f - 1) / 2) + (n(1, 0, 3) === 3 ? 1 : 0)), n: 1 });  // a loot box, better on deeper floors
    s.push({ id: [105, 152, 153, 154, 155][n(2, 0, 4)], n: n(3, 2, 4) }, { id: 160, n: n(4, 1, 2) }, { id: 102, n: n(5, 2, 5) + f });
    if (f >= 2) s.push({ id: 104, n: n(6, 1, 2) + (f >> 1) });
    if (f >= 3) s.push({ id: 147 + n(7, 0, 3), n: n(8, 1, 3) });
    s.push({ id: 112, n: n(9, 6, 14) }, { id: 22, n: 8 });
    return s;
  }

  class Chunk {
    constructor(cx, cz) {
      this.cx = cx; this.cz = cz; this.b = new Uint8Array(CS * CS * CH); this.hm = new Uint8Array(CS * CS);
      this.torches = []; this.mesh = null; this.dirty = true; this.map = null;
    }
    static idx(x, y, z) { return (y * CS + z) * CS + x; }
    get(x, y, z) { return this.b[(y * CS + z) * CS + x]; }
    updateColumn(x, z) {
      let y = CH - 1;
      while (y > 0 && !isOpaque[this.b[(y * CS + z) * CS + x]]) y--;
      this.hm[z * CS + x] = y;
    }
    rebuildMeta() {
      this.torches.length = 0;
      for (let z = 0; z < CS; z++) for (let x = 0; x < CS; x++) this.updateColumn(x, z);
      for (let i = 0; i < this.b.length; i++) if (BLOCKS[this.b[i]] && BLOCKS[this.b[i]].light) {
        const x = i % CS, z = ((i / CS) | 0) % CS, y = (i / (CS * CS)) | 0;
        this.torches.push(this.cx * CS + x, y, this.cz * CS + z);
      }
    }
  }

  function generateChunk(cx, cz, seed) {
    const c = new Chunk(cx, cz), b = c.b, X0 = cx * CS, Z0 = cz * CS;
    if (inDungeon(X0)) {
      for (let y = 0; y < CH; y++) for (let z = 0; z < CS; z++) for (let x = 0; x < CS; x++) b[(y * CS + z) * CS + x] = dungeonBlock(X0 + x, y, Z0 + z, seed);
      return c;
    }
    const P = 3, W = CS + P * 2;
    const hts = new Int16Array(W * W), bio = new Uint8Array(W * W);
    for (let z = 0; z < W; z++) for (let x = 0; x < W; x++) {
      hts[z * W + x] = heightAt(X0 + x - P, Z0 + z - P, seed);
      bio[z * W + x] = biomeAt(X0 + x - P, Z0 + z - P, seed);
    }
    const set = (x, y, z, id) => { if (x >= 0 && x < CS && z >= 0 && z < CS && y > 0 && y < CH) b[(y * CS + z) * CS + x] = id; };
    for (let z = 0; z < CS; z++) for (let x = 0; x < CS; x++) {
      const wx = X0 + x, wz = Z0 + z, h = hts[(z + P) * W + x + P], bm = bio[(z + P) * W + x + P];
      const beach = h <= SEA + 1 && bm !== 3;
      for (let y = 0; y <= Math.max(h, SEA); y++) {
        let id = 0;
        if (y === 0) id = 16;
        else if (y < h - 3) id = 3;
        else if (y < h) id = (bm === 2 || beach) ? (y < h - 2 && bm === 2 ? 50 : 8) : 2;
        else if (y === h) id = h < SEA ? (hash(wx, 3, wz, seed) < 0.3 ? 9 : 8) : (bm === 2 || beach) ? 8 : bm === 3 ? 17 : 1;
        else if (y <= SEA) id = 10;
        if (id === 3) {
          const r = hash(wx, y, wz, seed + 9), cl = hash(wx >> 1, y >> 1, wz >> 1, seed + 10);
          if (y < 16 && r < 0.01 && cl < 0.55) id = 15;
          else if (y < 22 && r < 0.008 && cl < 0.5) id = 14;
          else if (y < 44 && r < 0.02 && cl < 0.55) id = 13;
          else if (r < 0.035 && cl < 0.6) id = 12;
          else if (y < 30 && r < 0.043 && cl < 0.55) id = 55;
          else if (y < 14 && r < 0.048 && cl < 0.6) id = 56;
        }
        if (id) b[(y * CS + z) * CS + x] = id;
      }
      // caves: two crossing noise "tubes"
      for (let y = 2; y < h - 3; y++) {
        const n1 = noise3(wx / 18, y / 11, wz / 18, seed + 300), n2 = noise3(wx / 18, y / 11, wz / 18, seed + 400);
        if (Math.abs(n1 - 0.5) < 0.055 && Math.abs(n2 - 0.5) < 0.09) {
          const i = (y * CS + z) * CS + x; if (b[i] !== 16) b[i] = 0;
        }
      }
      // plants
      const top = b[(h * CS + z) * CS + x];
      if (h > SEA && b[((h + 1) * CS + z) * CS + x] === 0) {
        const r = hash(wx, 11, wz, seed);
        if (top === 1) {
          if (r < 0.08) b[((h + 1) * CS + z) * CS + x] = 38;
          else if (r < 0.088) b[((h + 1) * CS + z) * CS + x] = 36;
          else if (r < 0.096) b[((h + 1) * CS + z) * CS + x] = 37;
        } else if (top === 8 && bm === 2 && r < 0.006) {
          const ch = 1 + Math.floor(hash(wx, 12, wz, seed) * 3);
          for (let k = 1; k <= ch; k++) b[((h + k) * CS + z) * CS + x] = 18;
        }
      }
    }
    // trees can hang over chunk borders, so look a little outside
    for (let z = -P; z < CS + P; z++) for (let x = -P; x < CS + P; x++) {
      const h = hts[(z + P) * W + x + P], bm = bio[(z + P) * W + x + P];
      const th = treeAt(X0 + x, Z0 + z, seed, bm, h);
      if (!th) continue;
      if (x >= 0 && x < CS && z >= 0 && z < CS) { const tp = b[(h * CS + z) * CS + x]; if (tp !== 1 && tp !== 17) continue; }
      for (let y = h + th - 2; y <= h + th + 1; y++) {
        const r = y > h + th - 1 ? 1 : 2;
        for (let dz = -r; dz <= r; dz++) for (let dx = -r; dx <= r; dx++) {
          if (Math.abs(dx) === r && Math.abs(dz) === r && (r === 1 || hash(X0 + x + dx, y, Z0 + z + dz, seed) < 0.5)) continue;
          const lx = x + dx, lz = z + dz;
          if (lx >= 0 && lx < CS && lz >= 0 && lz < CS && y < CH && b[(y * CS + lz) * CS + lx] === 0) set(lx, y, lz, 7);
        }
      }
      for (let k = 1; k <= th; k++) set(x, h + k, z, 6);
    }
    // castles that reach into this chunk
    for (let rz = Math.floor((Z0 - CW) / REG); rz <= Math.floor((Z0 + CS) / REG); rz++)
      for (let rx = Math.floor((X0 - CW) / REG); rx <= Math.floor((X0 + CS) / REG); rx++) {
        const cs = castleAt(rx, rz, seed);
        if (!cs || cs.x0 >= X0 + CS || cs.x0 + CW <= X0 || cs.z0 >= Z0 + CS || cs.z0 + CW <= Z0) continue;
        for (let z = Math.max(0, cs.z0 - Z0); z < Math.min(CS, cs.z0 + CW - Z0); z++)
          for (let x = Math.max(0, cs.x0 - X0); x < Math.min(CS, cs.x0 + CW - X0); x++)
            for (let y = Math.max(1, cs.base - 6); y < Math.min(CH, cs.base + 17); y++) {
              const id = castleBlock(cs, X0 + x - cs.x0, y, Z0 + z - cs.z0);
              if (id >= 0) b[(y * CS + z) * CS + x] = id;
            }
      }
    return c;
  }

  // ---------------------------------------------------------------- world
  const key = (cx, cz) => cx + ',' + cz;
  class World {
    constructor(seed) { this.seed = seed | 0; this.chunks = new Map(); this.edits = new Map(); this.onChange = null; }
    chunk(cx, cz, make) {
      const k = key(cx, cz); let c = this.chunks.get(k);
      if (!c && make) {
        c = generateChunk(cx, cz, this.seed);
        const e = this.edits.get(k);
        if (e) e.forEach((id, i) => { c.b[i] = id; });
        c.rebuildMeta(); this.chunks.set(k, c);
      }
      return c;
    }
    get(x, y, z) {
      if (y < 0) return 16; if (y >= CH) return 0;
      const c = this.chunk(Math.floor(x / CS), Math.floor(z / CS), true);
      return c.b[(y * CS + (z - c.cz * CS)) * CS + (x - c.cx * CS)];
    }
    peek(x, y, z) { // like get, but never generates (0 when not loaded)
      if (y < 0) return 16; if (y >= CH) return 0;
      const c = this.chunks.get(key(Math.floor(x / CS), Math.floor(z / CS)));
      return c ? c.b[(y * CS + (z - c.cz * CS)) * CS + (x - c.cx * CS)] : 0;
    }
    set(x, y, z, id) {
      if (y < 1 || y >= CH) return false;
      const cx = Math.floor(x / CS), cz = Math.floor(z / CS), c = this.chunk(cx, cz, true);
      const lx = x - cx * CS, lz = z - cz * CS, i = Chunk.idx(lx, y, lz), old = c.b[i];
      if (old === id) return false;
      c.b[i] = id;
      const k = key(cx, cz); let e = this.edits.get(k); if (!e) { e = new Map(); this.edits.set(k, e); }
      e.set(i, id);
      c.updateColumn(lx, lz);
      if (blk(old).light || blk(id).light) c.rebuildMeta();
      c.dirty = true;
      if (this.onChange) this.onChange(x, y, z, old, id);
      return true;
    }
    topY(x, z) { // highest solid block in a column
      for (let y = CH - 1; y > 0; y--) if (isSolid[this.get(x, y, z)]) return y;
      return 0;
    }
    skyAt(x, y, z) { // 1 in open air, falling off with depth below the sky line
      const c = this.chunks.get(key(Math.floor(x / CS), Math.floor(z / CS)));
      if (!c) return 1;
      const d = c.hm[(z - c.cz * CS) * CS + (x - c.cx * CS)] - y;
      return d < 0 ? 1 : Math.max(0.04, 0.85 - d * 0.14);
    }
    torchLightAt(x, y, z) {
      let best = 0;
      const cx = Math.floor(x / CS), cz = Math.floor(z / CS);
      for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
        const c = this.chunks.get(key(cx + dx, cz + dz)); if (!c) continue;
        const ts = c.torches;
        for (let i = 0; i < ts.length; i += 3) {
          const ddx = ts[i] + 0.5 - x, ddy = ts[i + 1] + 0.5 - y, ddz = ts[i + 2] + 0.5 - z;
          const l = 1 - Math.sqrt(ddx * ddx + ddy * ddy + ddz * ddz) / 8;
          if (l > best) best = l;
        }
      }
      return best;
    }
    encodeEdits() {
      const o = {};
      this.edits.forEach((m, k) => { const a = []; m.forEach((id, i) => a.push(i.toString(36) + '.' + id.toString(36))); if (a.length) o[k] = a.join(','); });
      return o;
    }
    decodeEdits(o) {
      this.edits.clear();
      for (const k in o) {
        const m = new Map();
        o[k].split(',').forEach(p => { const s = p.split('.'); if (s.length === 2) m.set(parseInt(s[0], 36), parseInt(s[1], 36)); });
        this.edits.set(k, m);
      }
    }
  }

  // ---------------------------------------------------------------- physics
  function blockBox(id) { // [y0, y1] of the collision box, or null
    if (!isSolid[id]) return null;
    const d = BLOCKS[id];
    return d.h < 1 ? d.h : 1;
  }
  function boxHits(world, x0, y0, z0, x1, y1, z1) {
    for (let y = Math.floor(y0); y <= Math.floor(y1 - 1e-6); y++)
      for (let z = Math.floor(z0); z <= Math.floor(z1 - 1e-6); z++)
        for (let x = Math.floor(x0); x <= Math.floor(x1 - 1e-6); x++) {
          const h = blockBox(world.get(x, y, z));
          if (h !== null && y + h > y0 + 1e-6) return true;
        }
    return false;
  }
  // b: {x,y,z (feet centre), vx,vy,vz, w, h}; sets b.onGround / b.hitWall / b.inWater
  function moveBody(world, b, dt, canStep) {
    const hw = b.w / 2;
    b.hitWall = false;
    const feetBlock = world.get(Math.floor(b.x), Math.floor(b.y + 0.2), Math.floor(b.z));
    const midBlock = world.get(Math.floor(b.x), Math.floor(b.y + b.h * 0.6), Math.floor(b.z));
    b.inWater = feetBlock === 10 || midBlock === 10;
    b.onLadder = blk(feetBlock).shape === 'ladder' || blk(midBlock).shape === 'ladder';
    const steps = Math.max(1, Math.ceil(Math.max(Math.abs(b.vx), Math.abs(b.vy), Math.abs(b.vz)) * dt / 0.4));
    const sdt = dt / steps;
    let ground = false;
    for (let s = 0; s < steps; s++) {
      // y
      const ny = b.y + b.vy * sdt;
      if (b.vy !== 0 && boxHits(world, b.x - hw, ny, b.z - hw, b.x + hw, ny + b.h, b.z + hw)) {
        const x0 = Math.floor(b.x - hw), x1 = Math.floor(b.x + hw - 1e-6), z0 = Math.floor(b.z - hw), z1 = Math.floor(b.z + hw - 1e-6);
        if (b.vy < 0) { // land on the highest top below our feet
          let top = ny;
          for (let yy = Math.floor(ny); yy <= Math.floor(b.y + 0.01); yy++)
            for (let zz = z0; zz <= z1; zz++) for (let xx = x0; xx <= x1; xx++) {
              const h = blockBox(world.get(xx, yy, zz));
              if (h !== null && yy + h <= b.y + 0.01 && yy + h > top) top = yy + h;
            }
          b.y = top; ground = true;
        } else { // bump the head
          let bottom = ny + b.h;
          for (let yy = Math.floor(b.y + b.h - 0.01); yy <= Math.floor(ny + b.h); yy++)
            for (let zz = z0; zz <= z1; zz++) for (let xx = x0; xx <= x1; xx++)
              if (blockBox(world.get(xx, yy, zz)) !== null && yy >= b.y + b.h - 0.01 && yy < bottom) bottom = yy;
          b.y = bottom - b.h;
        }
        b.vy = 0;
      } else b.y = ny;
      // x then z (with step-up for slabs, beds and other half blocks)
      for (const ax of ['x', 'z']) {
        const v = ax === 'x' ? b.vx : b.vz; if (!v) continue;
        const nx = ax === 'x' ? b.x + v * sdt : b.x, nz = ax === 'z' ? b.z + v * sdt : b.z;
        if (boxHits(world, nx - hw, b.y, nz - hw, nx + hw, b.y + b.h, nz + hw)) {
          if (canStep && (ground || b.onGround) && !boxHits(world, nx - hw, b.y + 0.55, nz - hw, nx + hw, b.y + 0.55 + b.h, nz + hw)) {
            // find the exact step height
            let sy = b.y + 0.55;
            while (sy - 0.05 > b.y && !boxHits(world, nx - hw, sy - 0.05, nz - hw, nx + hw, sy - 0.05 + b.h, nz + hw)) sy -= 0.05;
            b.y = sy; b.x = nx; b.z = nz;
          } else {
            b.hitWall = true;
            if (ax === 'x') b.vx = 0; else b.vz = 0;
          }
        } else { b.x = nx; b.z = nz; }
      }
    }
    b.onGround = ground || (b.vy <= 0 && boxHits(world, b.x - hw, b.y - 0.05, b.z - hw, b.x + hw, b.y, b.z + hw));
  }

  // DDA ray through blocks. Returns {x,y,z, nx,ny,nz, id, d} of the first targetable block.
  function raycast(world, ox, oy, oz, dx, dy, dz, maxD, hitWater) {
    let x = Math.floor(ox), y = Math.floor(oy), z = Math.floor(oz);
    const sx = dx > 0 ? 1 : -1, sy = dy > 0 ? 1 : -1, sz = dz > 0 ? 1 : -1;
    const tdx = Math.abs(1 / dx), tdy = Math.abs(1 / dy), tdz = Math.abs(1 / dz);
    let tx = (dx > 0 ? x + 1 - ox : ox - x) * tdx, ty = (dy > 0 ? y + 1 - oy : oy - y) * tdy, tz = (dz > 0 ? z + 1 - oz : oz - z) * tdz;
    let nx = 0, ny = 0, nz = 0, d = 0;
    for (let i = 0; i < 64 && d <= maxD; i++) {
      const id = world.peek(x, y, z);
      if (id && (id !== 10 || hitWater)) {
        const def = BLOCKS[id];
        let ok = true;
        if (def.shape === 'slab' || def.shape === 'bed') { // only the lower part is there
          const hy = oy + dy * d - y;
          if (hy > def.h + 0.01 && dy >= 0) ok = false;
        }
        if (ok) return { x, y, z, nx, ny, nz, id, d };
      }
      if (tx < ty && tx < tz) { x += sx; d = tx; tx += tdx; nx = -sx; ny = 0; nz = 0; }
      else if (ty < tz) { y += sy; d = ty; ty += tdy; nx = 0; ny = -sy; nz = 0; }
      else { z += sz; d = tz; tz += tdz; nx = 0; ny = 0; nz = -sz; }
    }
    return null;
  }
  function rayBox(ox, oy, oz, dx, dy, dz, x0, y0, z0, x1, y1, z1) {
    let tmin = 0, tmax = 1e9;
    const o = [ox, oy, oz], d = [dx, dy, dz], lo = [x0, y0, z0], hi = [x1, y1, z1];
    for (let i = 0; i < 3; i++) {
      if (Math.abs(d[i]) < 1e-9) { if (o[i] < lo[i] || o[i] > hi[i]) return -1; continue; }
      let a = (lo[i] - o[i]) / d[i], b = (hi[i] - o[i]) / d[i];
      if (a > b) { const tt = a; a = b; b = tt; }
      tmin = Math.max(tmin, a); tmax = Math.min(tmax, b);
      if (tmin > tmax) return -1;
    }
    return tmin;
  }

  // ---------------------------------------------------------------- mobs
  const MOBS = {
    zombie: { hp: 20, w: 0.6, h: 1.9, speed: 1.9, hostile: true, burns: true, drops: [[110, 0, 2]] },
    skeleton: { hp: 20, w: 0.6, h: 1.9, speed: 1.6, hostile: true, burns: true, drops: [[111, 0, 2], [112, 0, 2]] },
    creeper: { hp: 20, w: 0.6, h: 1.7, speed: 1.7, hostile: true, drops: [[113, 0, 2]] },
    spider: { hp: 16, w: 1.3, h: 0.9, speed: 2.8, hostile: true, drops: [[116, 0, 2]] },
    pig: { hp: 10, w: 0.9, h: 0.9, speed: 1.2, drops: [[106, 1, 3]] },
    cow: { hp: 10, w: 0.9, h: 1.4, speed: 1.1, drops: [[108, 1, 3], [114, 0, 2]] },
    sheep: { hp: 8, w: 0.9, h: 1.3, speed: 1.1, drops: [[39, 1, 1]] },
    trex: { hp: 60, w: 1.6, h: 3.0, speed: 3.0, hostile: true, dino: true, dmg: 6, reach: 2.2, drops: [[140, 2, 5], [142, 1, 2]] },
    raptor: { hp: 16, w: 0.7, h: 1.3, speed: 4.0, hostile: true, dino: true, dmg: 2, drops: [[140, 1, 2]] },
    trike: { hp: 30, w: 1.4, h: 1.9, speed: 1.2, dino: true, charges: true, dmg: 4, reach: 1.8, drops: [[140, 2, 4], [114, 1, 2]] },
    brachio: { hp: 50, w: 2.0, h: 5.3, speed: 0.9, dino: true, drops: [[140, 3, 6]] },
    // pets: follow their owner, fight monsters near them, can't be hurt
    cat: { hp: 1, w: 0.5, h: 0.6, speed: 5, pet: true, drops: [] },
    dog: { hp: 1, w: 0.6, h: 0.85, speed: 5, pet: true, drops: [] },
    babydino: { hp: 1, w: 0.6, h: 1.0, speed: 5, pet: true, base: 'trex', scale: 0.35, drops: [] },
    // dungeon floor bosses: a big version of a monster (base = how it looks and fights)
    zking: { name: 'THE ZOMBIE KING', hp: 80, w: 1.1, h: 3.4, speed: 2.2, hostile: true, boss: true, base: 'zombie', scale: 1.8, dmg: 5, reach: 2.4, drops: [[104, 2, 4], [102, 4, 8]] },
    squeen: { name: 'THE SPIDER QUEEN', hp: 100, w: 2.8, h: 1.9, speed: 3.2, hostile: true, boss: true, base: 'spider', scale: 2.2, dmg: 4, reach: 2.6, drops: [[104, 3, 5], [116, 6, 12]] },
    slord: { name: 'THE SKELETON LORD', hp: 110, w: 1.0, h: 3.2, speed: 1.8, hostile: true, boss: true, base: 'skeleton', scale: 1.7, dmg: 4, drops: [[104, 3, 6], [112, 10, 20]] },
    ralpha: { name: 'THE RAPTOR ALPHA', hp: 120, w: 1.3, h: 2.5, speed: 4.6, hostile: true, boss: true, dino: true, base: 'raptor', scale: 1.9, dmg: 5, reach: 2.2, drops: [[104, 4, 7], [140, 4, 8]] },
    dlord: { name: 'THE DINO OVERLORD', hp: 220, w: 2.4, h: 4.5, speed: 3.2, hostile: true, boss: true, dino: true, base: 'trex', scale: 1.5, dmg: 8, reach: 3.2, drops: [[104, 6, 10], [142, 2, 4]] },
  };
  const BOSSES = ['zking', 'squeen', 'slord', 'ralpha', 'dlord'];
  const MOB_TYPES = Object.keys(MOBS);

  // ---------------------------------------------------------------- vehicles
  // Placed from the Airplane / Helicopter items. The rider's page flies it and reports where it is;
  // the sim only keeps the list, who is riding, and lets an empty one settle onto the ground.
  // w/h: collision box while flying; bw/bh: the box you tap to get in.
  const VEHICLES = {
    plane: { item: 144, w: 1.2, h: 1.2, bw: 3.6, bh: 1.8 },
    heli: { item: 145, w: 1.4, h: 1.6, bw: 2.4, bh: 2.4 },
  };
  const VEH_TYPES = Object.keys(VEHICLES);
  // each player's colour, picked from their name: index into the page's 8 paint colours, and the matching wool for flags
  const paintOf = owner => { if (!owner) return 0; const s = owner.toLowerCase(); let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return (h >>> 0) % 8; };
  const PAINT_WOOL = [40, 44, 43, 45, 42, 41, 47, 39];
  const fin = v => typeof v === 'number' && isFinite(v);

  const dayLight = time => { const s = Math.sin(time * Math.PI * 2); return Math.max(0.12, Math.min(1, 0.55 + s * 1.6)); };

  // ---------------------------------------------------------------- simulation
  // One Sim per world. `send(msg, playerId|null)` delivers messages (null = everyone).
  class Sim {
    constructor(opts) {
      this.world = new World(opts.seed);
      this.diff = opts.difficulty || 'normal'; // peaceful | easy | normal
      this.time = opts.time != null ? opts.time : 0.02;
      this.players = new Map(); this.mobs = new Map(); this.arrows = []; this.tnts = [];
      this.chests = opts.chests || {};
      this.nextId = 1; this.send = opts.send || (() => {}); this.spawnTimer = 0; this.snapTimer = 0;
      this.rand = Math.random;
      this.pendingBlocks = [];
      this.world.onChange = (x, y, z, old, id) => this.pendingBlocks.push(x, y, z, id);
      this.vehicles = new Map();
      for (const v of opts.vehicles || []) if (VEHICLES[v.kind]) this.addVehicle(v.kind, v.x, v.y, v.z, v.yaw, v.owner);
      this.owned = opts.owned || []; // castles built from kits: { x0, z0, base, type, owner }
      this.dungeon = Object.assign({ entrance: null, unlocked: {}, lastKill: {} }, opts.dungeon || {});
      this.petData = opts.pets || {}; // lower-case owner name -> { kind, name, lvl, xp }
    }
    // the dungeon entrance: a small mossy shrine with the glowing stairwell, a few steps from the world spawn (built once)
    ensureEntrance(spawn) {
      if (this.dungeon.entrance) return;
      const W = this.world, ex = Math.floor(spawn.x) + 7, ez = Math.floor(spawn.z) + 7;
      let gy = W.topY(ex, ez);
      while (gy > 2 && [6, 7, 18].includes(W.get(ex, gy, ez))) gy--;
      for (let dz = -3; dz <= 3; dz++) for (let dx = -3; dx <= 3; dx++) {
        const x = ex + dx, z = ez + dz, edge = Math.abs(dx) === 3 || Math.abs(dz) === 3, corner = Math.abs(dx) === 3 && Math.abs(dz) === 3;
        for (let y = gy - 2; y <= gy; y++) W.set(x, y, z, 54);                               // platform
        for (let y = gy + 1; y <= gy + 6; y++) {
          let id = 0;
          if (corner && y <= gy + 4) id = 34;                                                  // pillars
          else if (y === gy + 5 && edge) id = 48;                                              // roof rim
          else if (y === gy + 5 && dx === 0 && dz === 0) id = 49;                              // light
          W.set(x, y, z, id);
        }
      }
      W.set(ex, gy, ez, 61);
      W.set(ex - 2, gy + 1, ez - 2, 22); W.set(ex + 2, gy + 1, ez - 2, 22); W.set(ex - 2, gy + 1, ez + 2, 22); W.set(ex + 2, gy + 1, ez + 2, 22);
      this.dungeon.entrance = { x: ex, y: gy, z: ez };
    }
    // ---- pets
    spawnPet(pid) {
      const p = this.players.get(pid); if (!p) return;
      for (const m of [...this.mobs.values()]) if (m.owner === pid) this.mobs.delete(m.id);
      const pd = this.petData[p.name.toLowerCase()]; if (!pd || !MOBS[pd.kind]) return;
      const m = this.spawnMob(pd.kind, p.x + 1, p.y + 0.5, p.z + 1);
      m.owner = pid; m.petName = pd.name; m.lvl = pd.lvl || 1;
    }
    petOf(pid) { for (const m of this.mobs.values()) if (m.owner === pid) return m; return null; }
    ownerAt(x, z) { // whose kit castle (x, z) is in, or ''
      for (const c of this.owned) if (x >= c.x0 && x < c.x0 + CW && z >= c.z0 && z < c.z0 + CW) return c.owner;
      return '';
    }
    blockedFor(p, x, z) { const o = this.ownerAt(x, z); return o && (!p || o.toLowerCase() !== p.name.toLowerCase()) ? o : ''; }
    buildKit(pid, type, cx, cz) {
      const p = this.players.get(pid), W = this.world;
      cx = Math.floor(cx); cz = Math.floor(cz);
      const x0 = cx - 10, z0 = cz - 10;
      let base = W.topY(cx, cz);
      while (base > 2 && [6, 7, 18].includes(W.get(cx, base, cz))) base--; // stand on the ground, not on a treetop
      for (const c of this.owned) if (x0 < c.x0 + CW + 2 && x0 + CW + 2 > c.x0 && z0 < c.z0 + CW + 2 && z0 + CW + 2 > c.z0) return 'Too close to ' + c.owner + "'s castle";
      if (base > CH - 18 || base < 3) return 'The ground is too high or too low here';
      const c = { x0, z0, base, seed: W.seed, type, kit: true, flag: PAINT_WOOL[paintOf(p.name)] };
      for (let y = Math.max(1, base - 6); y < Math.min(CH, base + 17); y++)
        for (let lz = 0; lz < CW; lz++) for (let lx = 0; lx < CW; lx++) {
          const id = castleBlock(c, lx, y, lz);
          if (id >= 0) W.set(x0 + lx, y, z0 + lz, id);
        }
      this.owned.push({ x0, z0, base, type, owner: p.name });
      this.chests[(x0 + 10) + ',' + (base + 1) + ',' + (z0 + 14)] = []; // its treasure chest starts empty: fill it yourself
      this.send({ t: 'owned', list: this.owned }, null);
      this.send({ t: 'msg', text: p.name + ' built a ' + CASTLES[type].name + '!' }, null);
      return '';
    }
    // owner: the name of the player who placed it; only they can fly it or pick it up
    addVehicle(kind, x, y, z, yaw, owner) {
      const d = VEHICLES[kind];
      const v = { id: this.nextId++, kind, owner: String(owner || ''), x, y, z, yaw: yaw || 0, pitch: 0, roll: 0, vx: 0, vy: 0, vz: 0, w: d.w, h: d.h, rider: null, settle: true };
      this.vehicles.set(v.id, v); return v;
    }
    ownsVehicle(p, v) { return !v.owner || (p && p.name.toLowerCase() === v.owner.toLowerCase()); }
    saveVehicles() { return [...this.vehicles.values()].map(v => ({ kind: v.kind, owner: v.owner, x: v.x, y: v.y, z: v.z, yaw: v.yaw })); }
    addPlayer(id, name, pos) {
      const p = { id, name, x: pos.x, y: pos.y, z: pos.z, yaw: 0, pitch: 0, dead: false, sleepAt: 0, skin: hash(name.length, name.charCodeAt(0) || 1, name.charCodeAt(1) || 2, 77) };
      this.players.set(id, p); this.spawnPet(id); return p;
    }
    removePlayer(id) {
      this.players.delete(id);
      for (const m of [...this.mobs.values()]) if (m.owner === id) this.mobs.delete(m.id);
      for (const v of this.vehicles.values()) if (v.rider === id) { v.rider = null; v.settle = true; }
    }
    spawnPoint() {
      const s = this.world.seed;
      for (let r = 0; r < 400; r += 4) for (let a = 0; a < 8; a++) {
        const x = Math.round(Math.cos(a) * r), z = Math.round(Math.sin(a) * r);
        const h = heightAt(x, z, s), bm = biomeAt(x, z, s);
        if (h > SEA + 1 && (bm === 0 || bm === 1)) {
          const top = this.world.topY(x, z);
          if (this.world.get(x, top, z) !== 1 || isSolid[this.world.get(x, top + 1, z)] || isSolid[this.world.get(x, top + 2, z)]) continue;
          return { x: x + 0.5, y: top + 1, z: z + 0.5 };
        }
      }
      return { x: 0.5, y: CH - 4, z: 0.5 };
    }
    handle(pid, m) {
      const p = this.players.get(pid);
      switch (m.t) {
        case 'move': if (p) { p.x = m.x; p.y = m.y; p.z = m.z; p.yaw = m.yaw; p.pitch = m.pitch; p.dead = !!m.dead; } break;
        case 'block': {
          const x = m.x | 0, y = m.y | 0, z = m.z | 0, old = this.world.get(x, y, z), o = this.blockedFor(p, x, z);
          if (old === 61 || old === 62 || (old === 16 && inDungeon(x))) { this.send({ t: 'blocks', l: [x, y, z, old] }, pid); break; }
          // someone else's kit castle: only its doors may be opened and closed
          if (o && !(blk(old).shape === 'door' && blk(m.id | 0).shape === 'door')) {
            this.send({ t: 'msg', text: 'This is ' + o + "'s castle" }, pid);
            this.send({ t: 'blocks', l: [x, y, z, old] }, pid); break;
          }
          this.world.set(x, y, z, m.id | 0); break;
        }
        case 'kit': {
          const it = ITEMS[m.item];
          if (!p || !it || !it.kit || !fin(m.x) || !fin(m.z)) break;
          const why = this.buildKit(pid, it.kit, m.x, m.z);
          if (why) { this.send({ t: 'msg', text: why }, pid); this.send({ t: 'drop', items: [[m.item, 1]] }, pid); }
          break;
        }
        case 'hit': this.hitMob(pid, m.id, m.dmg, m.kx, m.kz); break;
        case 'shoot': this.arrows.push({ x: m.x, y: m.y, z: m.z, vx: m.vx, vy: m.vy, vz: m.vz, owner: pid, life: 6, id: this.nextId++, dmg: Math.min(14, +m.dmg || 6) }); break;
        case 'stairs': { // tap a stairwell: down a floor, or back up to the entrance
          const x = m.x | 0, y = m.y | 0, z = m.z | 0, id = this.world.get(x, y, z);
          if (!p || (id !== 61 && id !== 62) || Math.hypot(p.x - x - 0.5, p.z - z - 0.5) > 7) break;
          let to, floor = 0;
          if (id === 62) { const e = this.dungeon.entrance || this.spawnPoint(); to = { x: e.x + 0.5, y: e.y + 1, z: e.z + 2.5 }; }
          else { floor = floorAt(x) + 1; to = floorStart(floor, this.world.seed); }
          for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) this.world.chunk(Math.floor(to.x / CS) + dx, Math.floor(to.z / CS) + dz, true);
          p.x = to.x; p.y = to.y; p.z = to.z;
          this.send({ t: 'tp', x: to.x, y: to.y, z: to.z, floor }, pid);
          this.send({ t: 'msg', text: p.name + (floor ? ' went down to Floor ' + floor : ' climbed back up to the surface') }, null);
          const pet = this.petOf(pid); if (pet) { pet.x = to.x + 1; pet.y = to.y; pet.z = to.z; }
          break;
        }
        case 'pet': { // use a pet basket/egg: this player's pet (a new one replaces the old, whose item comes back)
          const it = ITEMS[m.item]; if (!p || !it || !it.pet) break;
          const k = p.name.toLowerCase(), old = this.petData[k];
          if (old && old.kind !== it.pet) { const back = Object.keys(ITEMS).find(i => ITEMS[i].pet === old.kind); if (back) this.send({ t: 'drop', items: [[+back, 1]] }, pid); }
          const names = { cat: 'Whiskers', dog: 'Buddy', babydino: 'Chomper' };
          if (!old || old.kind !== it.pet) this.petData[k] = { kind: it.pet, name: names[it.pet], lvl: 1, xp: 0 };
          this.spawnPet(pid);
          this.send({ t: 'petinfo', pet: this.petData[k] }, pid); break;
        }
        case 'petname': {
          const pd = p && this.petData[p.name.toLowerCase()], nm = String(m.name || '').replace(/[^A-Za-z0-9 _-]/g, '').trim().slice(0, 16);
          if (!pd || !nm) break;
          pd.name = nm; const pet = this.petOf(pid); if (pet) pet.petName = nm;
          this.send({ t: 'petinfo', pet: pd }, pid); break;
        }
        case 'ignite': {
          const x = m.x | 0, y = m.y | 0, z = m.z | 0;
          if (this.world.get(x, y, z) === 32) { this.world.set(x, y, z, 0); this.tnts.push({ x: x + 0.5, y, z: z + 0.5, vx: 0, vy: 2, vz: 0, w: 0.98, h: 0.98, fuse: 3, id: this.nextId++ }); }
          break;
        }
        case 'chest': {
          const q0 = String(m.k).split(',').map(Number), o = this.blockedFor(p, q0[0], q0[2]);
          if (o) { this.send({ t: 'msg', text: 'Only ' + o + ' can open this chest' }, pid); break; }
          if (!this.chests[m.k]) { const q = String(m.k).split(',').map(Number); const loot = castleLoot(q[0], q[1], q[2], this.world.seed) || dungeonLoot(q[0], q[1], q[2], this.world.seed); if (loot) this.chests[m.k] = loot; }
          this.send({ t: 'chest', k: m.k, slots: this.chests[m.k] || [] }, pid); break;
        }
        case 'chestSet': { const q1 = String(m.k).split(',').map(Number); if (!this.blockedFor(p, q1[0], q1[2])) this.chests[m.k] = m.slots; break; }
        case 'sleep': if (p) { p.sleepAt = Date.now(); this.trySleep(pid); } break;
        case 'chat': if (p) this.send({ t: 'msg', text: p.name + ': ' + String(m.text).slice(0, 80) }, null); break;
        case 'vplace': {
          if (!p || !VEHICLES[m.kind] || !fin(m.x) || !fin(m.y) || !fin(m.z) || this.vehicles.size >= 60) break;
          this.addVehicle(m.kind, m.x, m.y, m.z, fin(m.yaw) ? m.yaw : 0, p.name); break;
        }
        case 'vride': {
          const v = this.vehicles.get(m.id); if (!v || !p) break;
          if (!this.ownsVehicle(p, v)) { this.send({ t: 'msg', text: 'This is ' + v.owner + "'s. Make your own at a crafting table!" }, pid); break; }
          if (v.rider != null && v.rider !== pid && this.players.has(v.rider)) { this.send({ t: 'msg', text: 'Someone is already in it' }, pid); break; }
          for (const o of this.vehicles.values()) if (o.rider === pid) o.rider = null;
          v.rider = pid; v.settle = false;
          this.send({ t: 'vride', id: v.id, kind: v.kind, x: v.x, y: v.y, z: v.z, yaw: v.yaw }, pid); break;
        }
        case 'vmove': {
          const v = this.vehicles.get(m.id);
          if (!v || v.rider !== pid || !fin(m.x) || !fin(m.y) || !fin(m.z)) break;
          v.x = m.x; v.y = m.y; v.z = m.z; v.yaw = +m.yaw || 0; v.pitch = +m.pitch || 0; v.roll = +m.roll || 0; break;
        }
        case 'vleave': {
          const v = this.vehicles.get(m.id); if (!v || v.rider !== pid) break;
          if (fin(m.x) && fin(m.y) && fin(m.z)) { v.x = m.x; v.y = m.y; v.z = m.z; }
          v.rider = null; v.pitch = 0; v.roll = 0; v.vy = 0; v.settle = true; break;
        }
        case 'vpickup': {
          const v = this.vehicles.get(m.id);
          if (!v || (v.rider != null && this.players.has(v.rider))) break;
          if (!this.ownsVehicle(p, v)) { this.send({ t: 'msg', text: 'Only ' + v.owner + ' can pick that up' }, pid); break; }
          this.vehicles.delete(v.id);
          this.send({ t: 'drop', items: [[VEHICLES[v.kind].item, 1]] }, pid); break;
        }
      }
    }
    trySleep(pid) {
      const night = dayLight(this.time) < 0.5;
      if (!night) { this.send({ t: 'msg', text: 'You can only sleep at night' }, pid); return; }
      const p = this.players.get(pid);
      for (const mob of this.mobs.values()) if (MOBS[mob.type].hostile && Math.hypot(mob.x - p.x, mob.z - p.z) < 8) {
        this.send({ t: 'msg', text: 'You may not rest now, there are monsters nearby' }, pid); return;
      }
      const now = Date.now();
      for (const q of this.players.values()) if (now - q.sleepAt > 20000) {
        this.send({ t: 'msg', text: p.name + ' is sleeping. Everyone needs to sleep to skip the night.' }, null); return;
      }
      this.time = 0.01;
      for (const mob of [...this.mobs.values()]) if (MOBS[mob.type].hostile) this.mobs.delete(mob.id);
      this.send({ t: 'time', time: this.time }, null);
      this.send({ t: 'msg', text: 'Good morning!' }, null);
    }
    hitMob(pid, id, dmg, kx, kz) {
      const m = this.mobs.get(id); if (!m || MOBS[m.type].pet) return; // pets can't be hurt
      const boss = MOBS[m.type].boss;
      m.hp -= Math.max(0.5, Math.min(40, +dmg || 1)); // up to 40: strength potion + warrior + points on a diamond sword
      m.hurt = 0.35; m.vx += (kx || 0) * (boss ? 1.5 : 6); m.vz += (kz || 0) * (boss ? 1.5 : 6); if (!boss) m.vy = 4.5;
      m.target = pid; m.angry = 20; m.panic = 4;
      if (m.hp <= 0) this.killMob(m, pid);
    }
    // pid: who gets the drops and XP; byPet: the pet of pid did it
    killMob(m, pid, byPet) {
      this.mobs.delete(m.id);
      if (pid == null) return;
      const d = MOBS[m.type], items = [];
      for (const q of d.drops) { const n = q[1] + Math.floor(this.rand() * (q[2] - q[1] + 1)); if (n > 0) items.push([q[0], n]); }
      if (items.length) this.send({ t: 'drop', items }, pid);
      this.send({ t: 'kill', type: m.type, pet: !!byPet, floor: m.floor || 0 }, pid);
      if (d.boss) { // a floor boss: open the stairs down, and everyone nearby gets the reward
        const f = m.floor || 1, br = bossRoom(f, this.world.seed);
        this.dungeon.unlocked[f] = true; this.dungeon.lastKill[f] = Date.now();
        this.world.set(br.cx, FY + 1, br.cz, 61);
        for (const p of this.players.values()) if (p.id !== pid && Math.hypot(p.x - m.x, p.z - m.z) < 30) this.send({ t: 'kill', type: m.type, floor: f, helped: true }, p.id);
        this.send({ t: 'msg', text: d.name + ' IS DEFEATED! The stairs to Floor ' + (f + 1) + ' are open.' }, null);
      }
    }
    spawnMob(type, x, y, z) {
      const d = MOBS[type];
      const m = { id: this.nextId++, type, x, y, z, vx: 0, vy: 0, vz: 0, w: d.w, h: d.h, hp: d.hp, maxHp: d.hp, yaw: this.rand() * 6.28,
        hurt: 0, fuse: 0, cool: 1, wander: 0, wx: 0, wz: 0, target: null, angry: 0, panic: 0, burning: false, onGround: false };
      this.mobs.set(m.id, m); return m;
    }
    nearestPlayer(x, y, z, maxD) {
      let best = null, bd = maxD;
      for (const p of this.players.values()) {
        if (p.dead) continue;
        const d = Math.hypot(p.x - x, p.y - y, p.z - z);
        if (d < bd) { bd = d; best = p; }
      }
      return best ? { p: best, d: bd } : null;
    }
    lineOfSight(x0, y0, z0, x1, y1, z1) {
      const dx = x1 - x0, dy = y1 - y0, dz = z1 - z0, len = Math.hypot(dx, dy, dz);
      if (len < 0.01) return true;
      const h = raycast(this.world, x0, y0, z0, dx / len, dy / len, dz / len, len, false);
      return !h || h.d >= len - 0.5 || !isOpaque[h.id];
    }
    hurtPlayer(p, amt, fx, fz, src) {
      if (p.dead) return;
      const f = floorAt(p.x), scale = (this.diff === 'easy' ? 0.6 : 1) * (1 + Math.max(0, f - 1) * 0.15); // deeper floors hit harder
      const dx = p.x - fx, dz = p.z - fz, l = Math.hypot(dx, dz) || 1;
      this.send({ t: 'hurt', amt: Math.max(1, Math.round(amt * scale)), kx: dx / l, kz: dz / l, src: src || '' }, p.id);
    }
    explode(x, y, z, r, owner) {
      const W = this.world;
      for (let dy = -r; dy <= r; dy++) for (let dz = -r; dz <= r; dz++) for (let dx = -r; dx <= r; dx++) {
        const d = Math.hypot(dx, dy, dz);
        if (d > r + 0.3 - this.rand() * 0.8) continue;
        const bx = Math.floor(x + dx), by = Math.floor(y + dy), bz = Math.floor(z + dz), id = W.get(bx, by, bz);
        if (!id || id === 16 || id === 10 || this.ownerAt(bx, bz)) continue; // kit castles can't be blown up
        if (id === 32) { W.set(bx, by, bz, 0); this.tnts.push({ x: bx + 0.5, y: by, z: bz + 0.5, vx: (this.rand() - 0.5) * 6, vy: 5, vz: (this.rand() - 0.5) * 6, w: 0.98, h: 0.98, fuse: 0.6 + this.rand() * 1.2, id: this.nextId++ }); continue; }
        W.set(bx, by, bz, 0);
      }
      const reach = r * 2;
      for (const p of this.players.values()) {
        const d = Math.hypot(p.x - x, p.y + 0.9 - y, p.z - z);
        if (d < reach) this.hurtPlayer(p, (1 - d / reach) * (r >= 4 ? 20 : 16), x, z, 'boom');
      }
      for (const m of [...this.mobs.values()]) {
        if (MOBS[m.type].pet) continue;
        const d = Math.hypot(m.x - x, m.y - y, m.z - z);
        if (d < reach) { m.hp -= (1 - d / reach) * 18; m.vy = 6; m.vx += (m.x - x) / (d || 1) * 8; m.vz += (m.z - z) / (d || 1) * 8; if (m.hp <= 0) this.killMob(m, typeof owner === 'number' ? owner : null); }
      }
      this.send({ t: 'boom', x, y, z, r }, null);
    }
    darkEnough(x, y, z) {
      const sky = this.world.skyAt(x, y, z) * dayLight(this.time);
      return sky < 0.35 && this.world.torchLightAt(x + 0.5, y + 0.5, z + 0.5) < 0.2;
    }
    trySpawn() {
      const W = this.world;
      let hostile = 0, passive = 0;
      for (const m of this.mobs.values()) { if (MOBS[m.type].hostile) hostile++; else if (!MOBS[m.type].pet) passive++; }
      const np = Math.max(1, this.players.size);
      for (const p of this.players.values()) {
        if (p.dead) continue;
        if (inDungeon(p.x)) { this.dungeonTick(p); continue; }
        const a = this.rand() * Math.PI * 2, r = 20 + this.rand() * 18;
        const x = Math.floor(p.x + Math.cos(a) * r), z = Math.floor(p.z + Math.sin(a) * r);
        if (!W.chunks.has(key(Math.floor(x / CS), Math.floor(z / CS)))) continue;
        const top = W.topY(x, z);
        if (this.diff !== 'peaceful' && hostile < 10 * np) {
          // surface at night, or somewhere dark underground
          let y = top + 1;
          if (this.rand() < 0.5) y = 3 + Math.floor(this.rand() * Math.max(1, top - 6));
          if (isSolid[W.get(x, y - 1, z)] && !isSolid[W.get(x, y, z)] && !isSolid[W.get(x, y + 1, z)] && W.get(x, y, z) !== 10 && this.darkEnough(x, y, z)) {
            const r2 = this.rand();
            const type = r2 < 0.38 ? 'zombie' : r2 < 0.62 ? 'skeleton' : r2 < 0.84 ? 'creeper' : 'spider';
            this.spawnMob(type, x + 0.5, y, z + 0.5); hostile++;
            continue;
          }
        }
        if (passive < 6 * np && dayLight(this.time) > 0.6 && W.get(x, top, z) === 1 && !isSolid[W.get(x, top + 1, z)]) {
          // dinosaurs: about one daytime spawn in four
          if (this.rand() < 0.25) {
            let dinos = 0, trex = false;
            for (const mo of this.mobs.values()) if (MOBS[mo.type].dino) { dinos++; if (mo.type === 'trex') trex = true; }
            if (dinos < 4 * np) {
              const r3 = this.rand(), safe = this.diff === 'peaceful';
              let type, n;
              if (!safe && r3 < 0.12 && !trex) { type = 'trex'; n = 1; }
              else if (!safe && r3 < 0.35) { type = 'raptor'; n = 2 + Math.floor(this.rand() * 2); }
              else if (r3 < 0.75) { type = 'trike'; n = 1 + Math.floor(this.rand() * 2); }
              else { type = 'brachio'; n = 1; }
              for (let i = 0; i < n; i++) this.spawnMob(type, x + 0.5 + i * 1.5, top + 1, z + 0.5);
              passive += n;
              continue;
            }
          }
          const type = ['pig', 'cow', 'sheep'][Math.floor(this.rand() * 3)];
          const n = 1 + Math.floor(this.rand() * 3);
          for (let i = 0; i < n; i++) this.spawnMob(type, x + 0.5 + i * 0.7, top + 1, z + 0.5);
          passive += n;
        }
      }
    }
    // a pet: follow the owner (jump to them when far away) and fight monsters close to them
    tickPet(m, dt) {
      const p = this.players.get(m.owner), d = MOBS[m.type];
      if (!p) { this.mobs.delete(m.id); return; }
      m.cool -= dt;
      const dist = Math.hypot(p.x - m.x, p.z - m.z);
      if (dist > 20 || Math.abs(p.y - m.y) > 8) { m.x = p.x + 1; m.y = p.y + 0.3; m.z = p.z + 1; m.vx = m.vz = m.vy = 0; return; }
      let foe = null, fd = 10;
      for (const o of this.mobs.values()) {
        if (!MOBS[o.type].hostile) continue;
        const od = Math.hypot(o.x - m.x, o.z - m.z);
        if (od < fd && Math.hypot(o.x - p.x, o.z - p.z) < 12) { fd = od; foe = o; }
      }
      let tx = p.x, tz = p.z, stop = 2.2;
      if (foe) { tx = foe.x; tz = foe.z; stop = foe.w / 2 + 0.6; }
      const dx = tx - m.x, dz = tz - m.z, hd = Math.hypot(dx, dz) || 1;
      let mx = 0, mz = 0;
      if (hd > stop) { mx = dx / hd; mz = dz / hd; m.yaw = Math.atan2(dx, dz); }
      if (foe && hd < stop + 0.8 && m.cool <= 0) {
        m.cool = 0.9; foe.hp -= 2 + (m.lvl || 1); foe.hurt = 0.35; foe.vx += mx * 3; foe.vz += mz * 3; foe.target = m.owner; foe.angry = 20;
        this.send({ t: 'sfx', s: 'hit', x: m.x, y: m.y, z: m.z }, null);
        if (foe.hp <= 0) {
          this.killMob(foe, m.owner, true);
          const pd = this.petData[p.name.toLowerCase()];
          if (pd) { pd.xp = (pd.xp || 0) + 10; if (pd.xp >= 30 * pd.lvl) { pd.xp = 0; pd.lvl++; m.lvl = pd.lvl; this.send({ t: 'petinfo', pet: pd, up: true }, p.id); } }
        }
      }
      const acc = m.onGround ? 12 : 3, sp = d.speed * (hd > 6 ? 1.3 : 1);
      m.vx += (mx * sp - m.vx) * Math.min(1, acc * dt); m.vz += (mz * sp - m.vz) * Math.min(1, acc * dt);
      m.vy -= (m.inWater ? 6 : 26) * dt; if (m.inWater) m.vy = Math.max(m.vy, 1.5);
      moveBody(this.world, m, dt, true);
      if (m.hitWall && m.onGround && (mx || mz)) m.vy = 7.6;
    }
    tickMob(m, dt) {
      const d = MOBS[m.type], W = this.world, kind = d.base || m.type; // kind: how it fights (a boss fights like its base monster)
      if (d.pet) { this.tickPet(m, dt); return; }
      m.hurt = Math.max(0, m.hurt - dt); m.cool -= dt; m.angry -= dt; m.panic -= dt;
      if (d.hostile && inSafeRoom(m.x, m.z, W.seed)) { this.mobs.delete(m.id); this.send({ t: 'sfx', s: 'pop', x: m.x, y: m.y, z: m.z }, null); return; } // safe rooms: monsters vanish
      const near = this.nearestPlayer(m.x, m.y, m.z, d.hostile || (d.charges && m.angry > 0) ? (d.boss ? 40 : 22) : 10);
      let mx = 0, mz = 0, speed = d.speed;
      if (near && ((d.hostile && (kind !== 'spider' || d.boss || dayLight(this.time) < 0.5 || m.angry > 0 || inDungeon(m.x))) || (d.charges && m.angry > 0))) {
        const p = near.p, dx = p.x - m.x, dz = p.z - m.z, hd = Math.hypot(dx, dz) || 1;
        m.yaw = Math.atan2(dx, dz);
        if (kind === 'trex' && !m.roared) { m.roared = true; this.send({ t: 'sfx', s: 'roar', x: m.x, y: m.y, z: m.z }, null); }
        if (d.charges) speed = d.speed * 3;
        if (kind === 'skeleton') {
          const see = this.lineOfSight(m.x, m.y + 1.6, m.z, p.x, p.y + 1.5, p.z);
          if (hd > 9 || !see) { mx = dx / hd; mz = dz / hd; } else if (hd < 5) { mx = -dx / hd; mz = -dz / hd; }
          if (see && hd < 16 && m.cool <= 0) {
            m.cool = d.boss ? 1.1 : 2.2;
            const sy = m.y + (d.boss ? 2.6 : 1.5), ty = p.y + 1.4, tt = hd / 18;
            this.arrows.push({ x: m.x + dx / hd * 0.6, y: sy, z: m.z + dz / hd * 0.6, vx: dx / hd * 18, vy: (ty - sy) / tt + 10 * tt, vz: dz / hd * 18, owner: 'mob', life: 5, id: this.nextId++, dmg: d.dmg });
            this.send({ t: 'sfx', s: 'bow', x: m.x, y: m.y, z: m.z }, null);
          }
        } else if (kind === 'creeper') {
          if (hd < 3 && Math.abs(p.y - m.y) < 2.5) {
            if (m.fuse === 0) this.send({ t: 'sfx', s: 'hiss', x: m.x, y: m.y, z: m.z }, null);
            m.fuse += dt;
            if (m.fuse > 1.5) { this.mobs.delete(m.id); this.explode(m.x, m.y + 0.8, m.z, 3, 'creeper'); return; }
          } else {
            if (m.fuse > 0 && hd > 6) m.fuse = 0;
            if (m.fuse > 0) m.fuse = Math.max(0, m.fuse - dt * 0.5);
            mx = dx / hd; mz = dz / hd;
          }
        } else {
          const reach = d.reach || 1.6;
          if (hd > reach * 0.6) { mx = dx / hd; mz = dz / hd; }
          if (hd < reach && Math.abs(p.y - m.y) < Math.max(1.6, d.h * 0.6) && m.cool <= 0) {
            m.cool = kind === 'trex' ? 1.4 : 1;
            this.hurtPlayer(p, d.dmg || (kind === 'spider' ? 2 : this.diff === 'normal' ? 3 : 2), m.x, m.z, m.type);
            if (kind === 'trex') this.send({ t: 'sfx', s: 'roar', x: m.x, y: m.y, z: m.z }, null);
            if (kind === 'spider' && m.onGround) m.vy = 5;
          }
        }
      } else if (!d.hostile && !d.charges && m.panic > 0) {
        if (m.wander <= 0 || m.wander > 1) { m.wander = 1; const a = this.rand() * 6.28; m.wx = Math.sin(a); m.wz = Math.cos(a); }
        mx = m.wx; mz = m.wz; speed = d.speed * 2; m.yaw = Math.atan2(mx, mz);
      } else {
        m.roared = false;
        m.wander -= dt;
        if (m.wander <= 0) {
          m.wander = 3 + this.rand() * 5;
          if (this.rand() < 0.55) { const a = this.rand() * 6.28; m.wx = Math.sin(a); m.wz = Math.cos(a); } else { m.wx = 0; m.wz = 0; }
        }
        mx = m.wx; mz = m.wz; speed = d.speed * 0.6;
        if (mx || mz) m.yaw = Math.atan2(mx, mz);
      }
      // movement + physics
      const acc = m.onGround ? 10 : 2;
      m.vx += (mx * speed - m.vx) * Math.min(1, acc * dt);
      m.vz += (mz * speed - m.vz) * Math.min(1, acc * dt);
      m.vy -= (m.inWater ? 6 : 26) * dt;
      if (m.inWater) { m.vy = Math.max(m.vy, -2); if (mx || mz || d.hostile) m.vy += 14 * dt; }
      moveBody(W, m, dt, true);
      if (m.hitWall && m.onGround && (mx || mz)) m.vy = 7.6;
      if (m.y < -10) { this.mobs.delete(m.id); return; }
      // sunlight burns zombies and skeletons
      m.burning = false;
      if (d.burns && dayLight(this.time) > 0.6 && !m.inWater && W.skyAt(Math.floor(m.x), Math.floor(m.y + 1.5), Math.floor(m.z)) >= 1) {
        m.burning = true; m.hp -= dt * 1.2; if (m.hp <= 0) { this.killMob(m, null); return; }
      }
      // despawn far away
      const any = this.nearestPlayer(m.x, m.y, m.z, d.boss ? 60 : 90);
      if (!any) this.mobs.delete(m.id);
    }
    // dungeon: monsters in the dark halls, and the floor boss when someone walks into the boss room
    dungeonTick(p) {
      const W = this.world, f = floorAt(p.x); if (f < 1) return;
      const br = bossRoom(f, W.seed), inBoss = p.x >= br.x0 && p.x <= br.x1 + 1 && p.z >= br.z0 && p.z <= br.z1 + 1;
      if (inBoss && Date.now() - (this.dungeon.lastKill[f] || 0) > 120000 && ![...this.mobs.values()].some(m => MOBS[m.type].boss && m.floor === f)) {
        const type = BOSSES[(f - 1) % BOSSES.length], m = this.spawnMob(type, br.cx + 0.5, FY + 1, br.cz + 0.5);
        m.floor = f; m.hp = m.maxHp = Math.round(MOBS[type].hp * (1 + Math.floor((f - 1) / BOSSES.length) * 0.6));
        this.send({ t: 'msg', text: MOBS[type].name + ' HAS APPEARED!' }, null);
        this.send({ t: 'sfx', s: 'roar', x: br.cx, y: FY + 1, z: br.cz }, null);
      }
      if (this.diff === 'peaceful') return;
      let near = 0;
      for (const m of this.mobs.values()) if (MOBS[m.type].hostile && Math.hypot(m.x - p.x, m.z - p.z) < 40) near++;
      if (near >= 3 + f * 2) return;
      const a = this.rand() * Math.PI * 2, r = 10 + this.rand() * 14;
      const x = Math.floor(p.x + Math.cos(a) * r), z = Math.floor(p.z + Math.sin(a) * r), y = FY + 1;
      if (inSafeRoom(x + 0.5, z + 0.5, W.seed) || (x >= br.x0 && x <= br.x1 && z >= br.z0 && z <= br.z1)) return;
      if (!W.chunks.has(key(Math.floor(x / CS), Math.floor(z / CS))) || isSolid[W.get(x, y, z)] || isSolid[W.get(x, y + 1, z)] || !isSolid[W.get(x, y - 1, z)]) return;
      const pool = ['zombie', 'spider', 'zombie'].concat(f >= 2 ? ['skeleton', 'skeleton'] : [], f >= 3 ? ['creeper', 'raptor'] : [], f >= 5 ? ['trike'] : []);
      this.spawnMob(pool[Math.floor(this.rand() * pool.length)], x + 0.5, y, z + 0.5);
    }
    tickArrow(a, dt) {
      a.life -= dt;
      const steps = 4, sdt = dt / steps;
      for (let s = 0; s < steps; s++) {
        a.vy -= 20 * sdt;
        a.x += a.vx * sdt; a.y += a.vy * sdt; a.z += a.vz * sdt;
        if (isSolid[this.world.get(Math.floor(a.x), Math.floor(a.y), Math.floor(a.z))]) { a.life = Math.min(a.life, 0.01); a.vx = a.vy = a.vz = 0; return; }
        if (a.owner === 'mob') {
          for (const p of this.players.values()) {
            if (!p.dead && Math.abs(p.x - a.x) < 0.4 && Math.abs(p.z - a.z) < 0.4 && a.y > p.y && a.y < p.y + 1.8) {
              this.hurtPlayer(p, a.dmg || (this.diff === 'normal' ? 3 : 2), a.x - a.vx, a.z - a.vz, 'arrow'); a.life = 0; return;
            }
          }
        } else {
          for (const m of this.mobs.values()) {
            if (MOBS[m.type].pet) continue;
            const hw = m.w / 2 + 0.1;
            if (Math.abs(m.x - a.x) < hw && Math.abs(m.z - a.z) < hw && a.y > m.y && a.y < m.y + m.h) {
              const sp = Math.hypot(a.vx, a.vz) || 1;
              this.hitMob(a.owner, m.id, a.dmg || 6, a.vx / sp, a.vz / sp); a.life = 0; return;
            }
          }
        }
      }
    }
    tick(dt) {
      this.time = (this.time + dt / DAY_SECONDS) % 1;
      this.spawnTimer -= dt;
      if (this.spawnTimer <= 0) {
        this.spawnTimer = 1.5;
        // keep the land around every player loaded (the server has no renderer doing it) and forget far chunks
        for (const p of this.players.values()) {
          const pcx = Math.floor(p.x / CS), pcz = Math.floor(p.z / CS);
          for (let dz = -3; dz <= 3; dz++) for (let dx = -3; dx <= 3; dx++) this.world.chunk(pcx + dx, pcz + dz, true);
        }
        if (this.unloadFar) for (const [k, c] of this.world.chunks) {
          let keep = false;
          for (const p of this.players.values()) if (Math.abs(c.cx - Math.floor(p.x / CS)) <= 6 && Math.abs(c.cz - Math.floor(p.z / CS)) <= 6) keep = true;
          if (!keep) this.world.chunks.delete(k);
        }
        this.trySpawn();
      }
      for (const m of [...this.mobs.values()]) this.tickMob(m, dt);
      for (const a of this.arrows) this.tickArrow(a, dt);
      this.arrows = this.arrows.filter(a => a.life > 0);
      for (const tn of this.tnts) {
        tn.fuse -= dt; tn.vy -= 20 * dt; tn.vx *= 0.9; tn.vz *= 0.9;
        moveBody(this.world, tn, dt, false);
        if (tn.fuse <= 0) { tn.dead = true; this.explode(tn.x, tn.y + 0.5, tn.z, 4, 'tnt'); }
      }
      this.tnts = this.tnts.filter(tn => !tn.dead);
      // an empty vehicle left in the air drops down onto the ground (only where the land is loaded)
      for (const v of this.vehicles.values()) {
        if (v.rider != null || !v.settle || !this.world.chunks.has(key(Math.floor(v.x / CS), Math.floor(v.z / CS)))) continue;
        v.vx = 0; v.vz = 0; v.vy = Math.max(-20, v.vy - 20 * dt);
        moveBody(this.world, v, dt, false);
        if (v.onGround || v.inWater || v.y < 1) { v.vy = 0; v.settle = false; }
      }
      if (this.pendingBlocks.length) { const l = this.pendingBlocks; this.pendingBlocks = []; this.send({ t: 'blocks', l }, null); }
      this.snapTimer -= dt;
      if (this.snapTimer <= 0) { this.snapTimer = 0.1; this.send(this.snapshot(), null); }
    }
    snapshot() {
      const r = v => Math.round(v * 100) / 100;
      const mobs = [];
      for (const m of this.mobs.values()) {
        const e = [m.id, MOB_TYPES.indexOf(m.type), r(m.x), r(m.y), r(m.z), r(m.yaw),
          (m.hurt > 0 ? 1 : 0) | (m.fuse > 0 && (m.fuse * 6 | 0) % 2 ? 2 : 0) | (m.burning ? 4 : 0) | (Math.hypot(m.vx, m.vz) > 0.3 ? 8 : 0)];
        if (MOBS[m.type].boss) e.push(Math.max(0, Math.round(m.hp / m.maxHp * 100)), m.floor || 1);          // boss health bar
        else if (m.owner != null) { const o = this.players.get(m.owner); e.push(m.petName || '', o ? o.name : '', m.lvl || 1); } // pet name tag
        mobs.push(e);
      }
      const arrows = this.arrows.map(a => [a.id, r(a.x), r(a.y), r(a.z), r(a.vx), r(a.vy), r(a.vz)]);
      const tnts = this.tnts.map(tn => [tn.id, r(tn.x), r(tn.y), r(tn.z), (tn.fuse * 4 | 0) % 2]);
      const players = [];
      for (const p of this.players.values()) players.push([p.id, p.name, r(p.x), r(p.y), r(p.z), r(p.yaw), r(p.pitch), p.dead ? 1 : 0, p.skin]);
      const vehicles = [];
      for (const v of this.vehicles.values()) vehicles.push([v.id, VEH_TYPES.indexOf(v.kind), r(v.x), r(v.y), r(v.z), r(v.yaw), r(v.pitch), r(v.roll), v.rider || 0, v.owner]);
      return { t: 'snap', time: r(this.time * 1000) / 1000, mobs, arrows, tnts, players, vehicles };
    }
  }

  return { CS, CH, SEA, DAY_SECONDS, TILES, T, BLOCKS, NBLOCKS, ITEMS, WOOL, blk, isOpaque, isSolid, itemName, stackSize,
    isItem, allCreative, RECIPES, FUEL, hash, noise2, heightAt, biomeAt, Chunk, World, generateChunk, moveBody, raycast,
    rayBox, MOBS, MOB_TYPES, VEHICLES, VEH_TYPES, dayLight, Sim, key,
    CASTLES, castleAt, REG, CW, isChest, paintOf, inDungeon, floorAt, inSafeRoom, bossRoom, floorStart, FY };
});
EOF

game.js: the browser side. New: XP, levels, classes and points, the 31 achievements and the announcer, opening loot boxes, pets (model, name tags, petting), the boss health bar, the floor and safe room labels, the darker dungeon look, the HERO screen and the textures and icons for all of it.

cat > /root/blockcraft/game.js <<'EOF'
// BlockCraft client: textures, WebGL rendering, controls, inventory/crafting, menus, saving and networking.
(() => {
  'use strict';
  const E = window.BC;
  const { CS, CH, BLOCKS, ITEMS, blk, isOpaque, isSolid, T } = E;
  const $ = id => document.getElementById(id);
  const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
  const cv = $('gl'), hud = $('hud'), hx = hud.getContext('2d');
  const isTouch = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window;
  if (isTouch) document.body.classList.add('touch');
  const BASE = location.pathname.replace(/[^/]*$/, '');

  // ---------------------------------------------------------------- settings (per device)
  const cfg = { sound: true, dist: isTouch ? 3 : 4, coords: false, name: '', code: '', mpMode: 'survival' };
  try { Object.assign(cfg, JSON.parse(localStorage.getItem('blockcraft_cfg') || '{}')); } catch (e) {}
  const saveCfg = () => { try { localStorage.setItem('blockcraft_cfg', JSON.stringify(cfg)); } catch (e) {} };

  // ---------------------------------------------------------------- sound (synthesised)
  let actx = null, noiseBuf = null;
  function audio() {
    if (!cfg.sound) return null;
    if (!actx) { try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } }
    if (actx.state === 'suspended') actx.resume();
    return actx;
  }
  function tone(f0, f1, dur, type, vol, delay) {
    const a = audio(); if (!a) return;
    const t0 = a.currentTime + (delay || 0), o = a.createOscillator(), g = a.createGain();
    o.type = type || 'square'; o.frequency.setValueAtTime(f0, t0); o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t0 + dur);
    g.gain.setValueAtTime(vol, t0); g.gain.exponentialRampToValueAtTime(0.0005, t0 + dur);
    o.connect(g).connect(a.destination); o.start(t0); o.stop(t0 + dur);
  }
  function noise(dur, vol, f0, f1, delay) {
    const a = audio(); if (!a) return;
    if (!noiseBuf) { noiseBuf = a.createBuffer(1, a.sampleRate, a.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; }
    const t0 = a.currentTime + (delay || 0), s = a.createBufferSource(), f = a.createBiquadFilter(), g = a.createGain();
    s.buffer = noiseBuf; f.type = 'lowpass'; f.frequency.setValueAtTime(f0, t0); f.frequency.exponentialRampToValueAtTime(Math.max(30, f1), t0 + dur);
    g.gain.setValueAtTime(vol, t0); g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
    s.connect(f).connect(g).connect(a.destination); s.start(t0, Math.random() * 0.5); s.stop(t0 + dur);
  }
  function sfx(name, x, y, z) {
    let v = 1;
    if (x != null && pl) { const d = Math.hypot(x - pl.x, y - pl.y, z - pl.z); v = 1 - d / 28; if (v <= 0) return; }
    switch (name) {
      case 'dig': noise(0.12, 0.25 * v, 1400, 300); break;
      case 'break': noise(0.22, 0.35 * v, 2200, 200); break;
      case 'place': noise(0.09, 0.3 * v, 900, 200); break;
      case 'step': noise(0.05, 0.06 * v, 700, 300); break;
      case 'hurt': tone(420, 180, 0.22, 'sawtooth', 0.12 * v); break;
      case 'hit': noise(0.08, 0.3 * v, 3000, 500); tone(260, 120, 0.12, 'square', 0.05 * v); break;
      case 'pop': tone(500, 1100, 0.08, 'sine', 0.12 * v); break;
      case 'eat': for (let i = 0; i < 3; i++) noise(0.07, 0.2 * v, 1800, 600, i * 0.12); break;
      case 'fanfare': [523, 659, 784, 1046].forEach((f, i) => tone(f, f, 0.16, 'square', 0.05, i * 0.1)); break;
      case 'drink': for (let i = 0; i < 3; i++) tone(300 + i * 60, 200, 0.1, 'sine', 0.12 * v, i * 0.13); tone(700, 1400, 0.3, 'triangle', 0.06, 0.4); break;
      case 'boom': noise(1.4, 0.7 * v, 900, 40); break;
      case 'hiss': noise(1.4, 0.18 * v, 6000, 3000); break;
      case 'bow': tone(300, 900, 0.12, 'triangle', 0.1 * v); break;
      case 'door': noise(0.1, 0.25 * v, 600, 150); tone(140, 90, 0.1, 'square', 0.05 * v); break;
      case 'splash': noise(0.4, 0.2 * v, 2500, 300); break;
      case 'groan': tone(140, 90, 0.7, 'sawtooth', 0.05 * v); tone(150, 80, 0.7, 'square', 0.02 * v, 0.05); break;
      case 'tool': tone(900, 300, 0.25, 'square', 0.06); break;
      case 'craft': tone(600, 900, 0.07, 'square', 0.05); tone(900, 1200, 0.07, 'square', 0.05, 0.08); break;
      case 'die': tone(400, 60, 0.9, 'sawtooth', 0.15); break;
      case 'roar': tone(110, 45, 1.3, 'sawtooth', 0.22 * v); tone(160, 60, 1.1, 'square', 0.08 * v, 0.05); noise(1.2, 0.35 * v, 900, 120); break;
    }
  }

  // ---------------------------------------------------------------- textures
  function rng(seed) { let s = seed >>> 0 || 1; return () => { s ^= s << 13; s ^= s >>> 17; s ^= s << 5; return (s >>> 0) / 4294967296; }; }
  const hashStr = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); return h >>> 0; };
  const hexRgb = h => { const n = parseInt(h.slice(1), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; };
  const PAINTS = ['#d23a32', '#2f6fd8', '#3fae3a', '#8932b8', '#f0c020', '#f07d19', '#f38baa', '#20b8b0'];
  const paintFor = E.paintOf;
  const atlas = document.createElement('canvas'); atlas.width = atlas.height = 256;
  const ac = atlas.getContext('2d');
  function drawTile(name, i) {
    const img = ac.createImageData(16, 16), d = img.data, r = rng(hashStr(name) + 7);
    const P = (x, y, c, a) => { if (x < 0 || y < 0 || x > 15 || y > 15) return; const k = (y * 16 + x) * 4; d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = a == null ? 255 : a; };
    const V = (c, f) => [clamp(c[0] * f, 0, 255) | 0, clamp(c[1] * f, 0, 255) | 0, clamp(c[2] * f, 0, 255) | 0];
    const C = hexRgb;
    const fillN = (hex, amt) => { const c = C(hex); for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, 1 + (r() - 0.5) * amt)); };
    const rect = (x0, y0, x1, y1, hex, amt) => { const c = C(hex); for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) P(x, y, V(c, 1 + (r() - 0.5) * (amt || 0))); };
    const clear = () => { for (let k = 0; k < d.length; k++) d[k] = 0; };
    const voronoi = (n, cols, edge, edgeW) => {
      const pts = []; for (let k = 0; k < n; k++) pts.push([r() * 16, r() * 16, C(cols[k % cols.length]), 0.85 + r() * 0.3]);
      for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) {
        let d1 = 99, d2 = 99, best = 0;
        for (let k = 0; k < n; k++) for (let oy = -16; oy <= 16; oy += 16) for (let ox = -16; ox <= 16; ox += 16) {
          const dd = Math.hypot(x + 0.5 - pts[k][0] - ox, y + 0.5 - pts[k][1] - oy);
          if (dd < d1) { d2 = d1; d1 = dd; best = k; } else if (dd < d2) d2 = dd;
        }
        if (edge && d2 - d1 < edgeW) P(x, y, V(C(edge), 0.9 + r() * 0.2)); else P(x, y, V(pts[best][2], pts[best][3] + (r() - 0.5) * 0.12));
      }
    };
    const ore = hex => { fillN('#7f7f7f', 0.22); for (let k = 0; k < 4; k++) { const cx = 2 + (r() * 12 | 0), cy = 2 + (r() * 12 | 0); for (let j = 0; j < 5; j++) P(cx + (r() * 3 | 0) - 1, cy + (r() * 3 | 0) - 1, V(C(hex), 0.8 + r() * 0.4)); } };
    const planks = (hex) => {
      const c = C(hex);
      for (let y = 0; y < 16; y++) { const row = y >> 2, f = 0.88 + ((row * 7919) % 5) * 0.05; for (let x = 0; x < 16; x++) P(x, y, V(c, f + (r() - 0.5) * 0.1)); }
      for (let x = 0; x < 16; x++) for (const y of [3, 7, 11, 15]) P(x, y, V(c, 0.62));
      for (let row = 0; row < 4; row++) { const sx = (row * 5 + 3) % 16; for (let y = row * 4; y < row * 4 + 3; y++) P(sx, y, V(c, 0.7)); }
    };
    const face = (skin, eyes) => { fillN(skin, 0.15); for (const [x, y] of eyes) P(x, y, [20, 20, 20]); };
    switch (name) {
      case 'grass_top': fillN('#5f9e3c', 0.32); break;
      case 'dirt': fillN('#866043', 0.42); for (let k = 0; k < 14; k++) P(r() * 16 | 0, r() * 16 | 0, V(C('#866043'), 0.65)); break;
      case 'grass_side': fillN('#866043', 0.42); for (let x = 0; x < 16; x++) { const h = 3 + (r() < 0.45 ? 1 : 0) + (r() < 0.2 ? 1 : 0); for (let y = 0; y < h; y++) P(x, y, V(C('#5f9e3c'), 1 + (r() - 0.5) * 0.3)); } break;
      case 'stone': fillN('#7f7f7f', 0.22); for (let k = 0; k < 6; k++) { const x = r() * 14 | 0, y = r() * 16 | 0; P(x, y, [98, 98, 98]); P(x + 1, y, [98, 98, 98]); } break;
      case 'cobble': voronoi(9, ['#8a8a8a', '#7a7a7a', '#6c6c6c', '#949494'], '#4c4c4c', 1.1); break;
      case 'planks': planks('#a8844f'); break;
      case 'log_side': { const c = C('#6b5130'); for (let x = 0; x < 16; x++) { const f = 0.8 + r() * 0.35; for (let y = 0; y < 16; y++) P(x, y, V(c, f + (r() - 0.5) * 0.15)); } break; }
      case 'log_top': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) { const dd = Math.max(Math.abs(x - 7.5), Math.abs(y - 7.5)); P(x, y, V(C(dd > 6.5 ? '#6b5130' : (Math.floor(dd) % 2 ? '#b8945f' : '#a07c4a')), 1 + (r() - 0.5) * 0.12)); } break;
      case 'leaves': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) { if (r() < 0.16) P(x, y, [0, 0, 0], 0); else P(x, y, V(C('#3f7f28'), 0.7 + r() * 0.6)); } break;
      case 'sand': fillN('#dbcf9c', 0.14); break;
      case 'gravel': voronoi(18, ['#8f8a86', '#7a7470', '#a09a94', '#6a6460'], '#5a5450', 0.6); break;
      case 'water': { const c = C('#3a66d0'); for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, ((x + y * 3) % 8 < 2 ? 1.2 : 1) + (r() - 0.5) * 0.08)); break; }
      case 'glass': clear(); for (let k = 0; k < 16; k++) { P(k, 0, [216, 240, 248]); P(k, 15, [216, 240, 248]); P(0, k, [216, 240, 248]); P(15, k, [216, 240, 248]); } for (let k = 3; k < 7; k++) P(k, k, [255, 255, 255], 200); P(9, 4, [255, 255, 255], 160); P(10, 5, [255, 255, 255], 160); break;
      case 'coal_ore': ore('#222222'); break;
      case 'iron_ore': ore('#d8af93'); break;
      case 'gold_ore': ore('#fcee4b'); break;
      case 'diamond_ore': ore('#5decf5'); break;
      case 'bedrock': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(C('#555555'), 0.4 + r() * 1.1)); break;
      case 'snow': fillN('#f2f6f6', 0.06); break;
      case 'snow_side': fillN('#866043', 0.42); for (let x = 0; x < 16; x++) { const h = 3 + (r() < 0.5 ? 1 : 0); for (let y = 0; y < h; y++) P(x, y, V(C('#f2f6f6'), 1 - r() * 0.06)); } break;
      case 'cactus_side': { const c = C('#3f7f2a'); for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, (x % 4 === 0 ? 0.75 : 1) + (r() - 0.5) * 0.15)); for (let k = 0; k < 10; k++) P(r() * 16 | 0, r() * 16 | 0, C('#d8eaa8')); break; }
      case 'cactus_top': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) { const dd = Math.max(Math.abs(x - 7.5), Math.abs(y - 7.5)); P(x, y, V(C(dd > 5.5 ? '#3f7f2a' : '#5a9a3a'), 1 + (r() - 0.5) * 0.12)); } break;
      case 'table_top': planks('#a8844f'); for (let k = 0; k < 16; k++) { P(k, 0, [90, 60, 30]); P(k, 15, [90, 60, 30]); P(0, k, [90, 60, 30]); P(15, k, [90, 60, 30]); P(k, 7, [110, 76, 40]); P(7, k, [110, 76, 40]); } break;
      case 'table_side': case 'table_front': planks('#a8844f'); rect(0, 0, 15, 2, '#6b4a26', 0.1);
        if (name === 'table_front') { rect(3, 6, 7, 8, '#9a9a9a'); rect(3, 9, 4, 12, '#6b4a26'); rect(10, 5, 13, 6, '#9a9a9a'); rect(11, 7, 12, 13, '#6b4a26'); } break;
      case 'furnace_front': fillN('#6f6f6f', 0.22); rect(4, 8, 11, 13, '#1a1a1a'); rect(5, 12, 10, 13, '#3a2a1a'); rect(3, 3, 12, 4, '#4f4f4f'); break;
      case 'furnace_side': fillN('#707070', 0.22); for (let k = 0; k < 16; k++) { P(k, 0, [80, 80, 80]); P(k, 15, [80, 80, 80]); } break;
      case 'furnace_top': fillN('#8a8a8a', 0.15); break;
      case 'chest_front': case 'chest_side': case 'chest_top': {
        const c = C('#a2702d');
        for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, (y % 4 === 3 ? 0.82 : 1) + (r() - 0.5) * 0.1));
        for (let k = 0; k < 16; k++) { P(k, 0, [80, 50, 20]); P(k, 15, [80, 50, 20]); P(0, k, [80, 50, 20]); P(15, k, [80, 50, 20]); }
        if (name !== 'chest_top') for (let k = 1; k < 15; k++) P(k, 5, [80, 50, 20]);
        if (name === 'chest_front') rect(7, 4, 8, 7, '#c8c8c8');
        break;
      }
      case 'torch': clear(); rect(7, 9, 8, 15, '#7a5a32'); rect(7, 6, 8, 6, '#ff9d2a'); rect(7, 7, 8, 8, '#ffe066'); break;
      case 'door_bottom': case 'door_top': {
        planks('#8a6234');
        for (let k = 0; k < 16; k++) { P(0, k, [70, 45, 20]); P(15, k, [70, 45, 20]); P(k, name === 'door_top' ? 0 : 15, [70, 45, 20]); }
        if (name === 'door_top') { for (let y = 3; y <= 8; y++) for (let x = 3; x <= 12; x++) if (x !== 7 && x !== 8 && y !== 5) P(x, y, [0, 0, 0], 0); }
        else rect(12, 1, 13, 2, '#c8c8c8');
        break;
      }
      case 'bed_top': fillN('#c43a3a', 0.12); rect(1, 1, 14, 4, '#e8e8e8', 0.05); break;
      case 'bed_side': clear(); rect(0, 7, 15, 10, '#c43a3a', 0.1); rect(0, 11, 15, 12, '#e0e0e0', 0.05); rect(0, 13, 15, 15, '#8a6234', 0.1); break;
      case 'tnt_side': fillN('#d44030', 0.12); rect(0, 6, 15, 9, '#eeeeee', 0.04);
        for (const [x, y] of [[2, 6], [3, 6], [4, 6], [3, 7], [3, 8], [3, 9], [6, 6], [6, 7], [6, 8], [6, 9], [7, 7], [8, 6], [8, 7], [8, 8], [8, 9], [10, 6], [11, 6], [12, 6], [11, 7], [11, 8], [11, 9]]) P(x, y, [20, 20, 20]);
        break;
      case 'tnt_top': fillN('#d44030', 0.12); rect(7, 7, 8, 8, '#333333'); break;
      case 'tnt_bottom': fillN('#a83020', 0.12); break;
      case 'bricks': { fillN('#b5b0a8', 0.08); for (let row = 0; row < 4; row++) for (let b = 0; b < 3; b++) { const x0 = b * 8 - (row % 2) * 4; for (let y = row * 4; y < row * 4 + 3; y++) for (let x = x0; x < x0 + 7; x++) if (x >= 0 && x < 16) P(x, y, V(C('#975a49'), 1 + (r() - 0.5) * 0.2)); } break; }
      case 'stone_bricks': fillN('#7a7a7a', 0.15); for (let k = 0; k < 16; k++) { P(k, 7, [88, 88, 88]); P(k, 15, [88, 88, 88]); } for (let y = 0; y < 7; y++) P(7, y, [88, 88, 88]); for (let y = 8; y < 15; y++) P(15, y, [88, 88, 88]); break;
      case 'bookshelf': { planks('#a8844f'); const cols = ['#a83232', '#3250a8', '#32a850', '#a88a32', '#7a32a8']; for (const [y0, y1] of [[2, 6], [9, 13]]) for (let x = 1; x < 15; x += 2) { const c = cols[r() * 5 | 0]; rect(x, y0 + (r() < 0.3 ? 1 : 0), x + 1, y1, c, 0.15); } break; }
      case 'flower_red': case 'flower_yellow': { clear(); rect(7, 8, 7, 15, '#3f8a28'); P(6, 11, C('#3f8a28')); const pc = name === 'flower_red' ? '#d42020' : '#f0e020'; rect(6, 4, 8, 6, pc); P(7, 3, C(pc)); P(5, 5, C(pc)); P(9, 5, C(pc)); P(7, 5, C(name === 'flower_red' ? '#f0e020' : '#c08010')); break; }
      case 'tall_grass': clear(); for (let k = 0; k < 7; k++) { const x0 = 1 + (r() * 14 | 0), h = 6 + (r() * 9 | 0), lean = r() < 0.5 ? -1 : 1; for (let y = 0; y < h; y++) P(x0 + (y > h * 0.6 ? lean : 0), 15 - y, V(C('#5f9e3c'), 0.75 + r() * 0.5)); } break;
      case 'glowstone': voronoi(10, ['#f4d27a', '#ffe9a6', '#e8b85a'], '#9c7a3a', 0.8); break;
      case 'sandstone_side': fillN('#d8c98e', 0.1); rect(0, 0, 15, 2, '#e2d49c', 0.05); for (let k = 0; k < 16; k++) { P(k, 3, [180, 165, 115]); P(k, 12, [180, 165, 115]); } break;
      case 'sandstone_top': fillN('#dccf96', 0.1); break;
      case 'slab_side': fillN('#a8a8a8', 0.08); for (let k = 0; k < 16; k++) { P(k, 8, [120, 120, 120]); P(k, 15, [120, 120, 120]); } break;
      case 'slab_top': fillN('#b0b0b0', 0.08); for (let k = 0; k < 16; k++) { P(k, 0, [130, 130, 130]); P(k, 15, [130, 130, 130]); P(0, k, [130, 130, 130]); P(15, k, [130, 130, 130]); } break;
      case 'snow_block': fillN('#f6fafa', 0.05); break;
      case 'ladder': clear(); rect(2, 0, 3, 15, '#7a5a32', 0.1); rect(12, 0, 13, 15, '#7a5a32', 0.1); for (const y of [2, 6, 10, 14]) rect(2, y, 13, y, '#9a7a48', 0.1); break;
      case 'zombie_face': face('#5f9f4a', [[4, 6], [5, 6], [10, 6], [11, 6], [4, 7], [11, 7]]); rect(0, 0, 15, 2, '#3f7a30', 0.15); rect(7, 8, 8, 9, '#4a8038'); rect(5, 11, 10, 11, '#3a6a2a'); break;
      case 'zombie_skin': fillN('#5f9f4a', 0.2); break;
      case 'zombie_shirt': fillN('#2fa8a8', 0.15); break;
      case 'zombie_pants': fillN('#3b3f9f', 0.15); break;
      case 'skeleton_face': face('#c6c6c6', [[4, 6], [5, 6], [4, 7], [5, 7], [10, 6], [11, 6], [10, 7], [11, 7], [7, 9], [8, 9]]); for (let x = 4; x <= 11; x += 2) P(x, 12, [40, 40, 40]); break;
      case 'bone': fillN('#c6c6c6', 0.12); for (let k = 0; k < 10; k++) P(r() * 16 | 0, r() * 16 | 0, [138, 138, 138]); break;
      case 'creeper_face': case 'creeper_skin': {
        for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(C(r() < 0.3 ? '#7cd36a' : r() < 0.5 ? '#3f9a2f' : '#4fae3a'), 1));
        if (name === 'creeper_face') { rect(3, 4, 6, 7, '#101010'); rect(9, 4, 12, 7, '#101010'); rect(6, 8, 9, 11, '#101010'); rect(4, 10, 5, 13, '#101010'); rect(10, 10, 11, 13, '#101010'); }
        break;
      }
      case 'spider_face': fillN('#2e2620', 0.3); for (const [x, y] of [[3, 6], [4, 6], [11, 6], [12, 6], [6, 8], [9, 8], [5, 5], [10, 5]]) P(x, y, [200, 20, 20]); break;
      case 'spider_body': fillN('#2e2620', 0.35); for (let k = 0; k < 8; k++) P(r() * 16 | 0, r() * 16 | 0, [70, 60, 50]); break;
      case 'pig_face': face('#f0a5a2', [[3, 5], [12, 5]]); P(4, 5, [240, 240, 240]); P(11, 5, [240, 240, 240]); rect(5, 8, 10, 11, '#e58c8a'); P(6, 9, [120, 60, 60]); P(9, 9, [120, 60, 60]); break;
      case 'pig_skin': fillN('#f0a5a2', 0.1); break;
      case 'cow_face': fillN('#4a3424', 0.15); rect(5, 0, 10, 5, '#e8e8e8', 0.05); P(3, 6, [10, 10, 10]); P(12, 6, [10, 10, 10]); rect(4, 10, 11, 15, '#c8a890', 0.08); P(6, 12, [60, 40, 30]); P(9, 12, [60, 40, 30]); break;
      case 'cow_skin': for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(C(E.noise2(x / 5, y / 5, 9) > 0.55 ? '#4a3424' : '#e8e8e8'), 1 + (r() - 0.5) * 0.08)); break;
      case 'sheep_face': face('#d6bfa3', [[4, 7], [11, 7]]); rect(7, 10, 8, 10, '#c08a80'); rect(0, 0, 15, 3, '#e8e8e8', 0.1); break;
      case 'sheep_wool': fillN('#e8e8e8', 0.16); break;
      case 'player_face': fillN('#c99a7a', 0.08); rect(0, 0, 15, 3, '#4a3220', 0.15); rect(0, 4, 0, 6, '#4a3220'); rect(15, 4, 15, 6, '#4a3220');
        rect(3, 8, 4, 8, '#ffffff'); rect(11, 8, 12, 8, '#ffffff'); P(4, 8, [40, 70, 160]); P(11, 8, [40, 70, 160]); rect(6, 12, 9, 12, '#8a5040'); break;
      case 'player_hair': fillN('#4a3220', 0.2); break;
      case 'player_skin': fillN('#c99a7a', 0.08); break;
      case 'player_shirt': fillN('#2fa8c8', 0.12); break;
      case 'player_pants': fillN('#3b3f9f', 0.12); break;
      case 'arrow_tex': fillN('#8a6a40', 0.2); break;
      case 'tnt_lit': fillN('#ffffff', 0.04); break;
      case 'diamond_block': fillN('#5decf5', 0.1); for (let k = 0; k < 16; k++) { P(k, 0, [30, 160, 170]); P(k, 15, [30, 160, 170]); P(0, k, [30, 160, 170]); P(15, k, [30, 160, 170]); } rect(3, 3, 5, 5, '#c8fbff'); rect(9, 8, 10, 9, '#c8fbff'); P(12, 3, [200, 250, 255]); break;
      case 'mossy_bricks': fillN('#7a7a7a', 0.15); for (let k = 0; k < 16; k++) { P(k, 7, [88, 88, 88]); P(k, 15, [88, 88, 88]); } for (let y = 0; y < 7; y++) P(7, y, [88, 88, 88]); for (let y = 8; y < 15; y++) P(15, y, [88, 88, 88]);
        for (let k = 0; k < 40; k++) { const x = r() * 16 | 0, y = r() * 16 | 0; if (E.noise2(x / 4, y / 4, 5) > 0.45) P(x, y, V(C('#4f7a2f'), 0.8 + r() * 0.4)); } break;
      case 'trex_skin': case 'raptor_skin': case 'trike_skin': case 'brachio_skin': {
        const base = { trex_skin: '#5a6b3a', raptor_skin: '#a8743a', trike_skin: '#7a8a5a', brachio_skin: '#7f907a' }[name];
        for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(C(base), (name !== 'brachio_skin' && (x + y * 2) % 7 < 2 ? 0.75 : 1) + (r() - 0.5) * 0.2));
        break;
      }
      case 'trex_face': case 'raptor_face': {
        fillN(name === 'trex_face' ? '#5a6b3a' : '#a8743a', 0.2);
        rect(2, 3, 4, 5, '#f0d020'); P(3, 4, [10, 10, 10]); rect(11, 3, 13, 5, '#f0d020'); P(12, 4, [10, 10, 10]);
        rect(5, 2, 10, 3, '#3a4a20'); P(6, 8, [30, 20, 20]); P(9, 8, [30, 20, 20]);
        rect(1, 11, 14, 12, '#401010'); for (let x = 1; x <= 14; x += 2) { P(x, 10, [245, 245, 230]); P(x + 1, 13, [245, 245, 230]); }
        break;
      }
      case 'trike_face': fillN('#7a8a5a', 0.15); rect(3, 5, 4, 6, '#101010'); rect(11, 5, 12, 6, '#101010'); rect(5, 10, 10, 15, '#5a4a3a', 0.1); P(7, 12, [30, 20, 20]); P(8, 12, [30, 20, 20]); break;
      case 'brachio_face': fillN('#7f907a', 0.12); rect(3, 5, 4, 6, '#101010'); rect(11, 5, 12, 6, '#101010'); rect(4, 12, 11, 12, '#4a5a40'); break;
      case 'paint0': case 'paint1': case 'paint2': case 'paint3': case 'paint4': case 'paint5': case 'paint6': case 'paint7': {
        // each player's vehicles get their own colour (PAINTS, picked from the owner's name)
        const c = PAINTS[+name.slice(5)];
        fillN(c, 0.08); rect(0, 6, 15, 8, '#f4f4f4', 0.03); for (let k = 0; k < 16; k += 4) P(k, 2, V(C(c), 0.7)); break;
      }
      case 'plane_wing': fillN('#e8e8e8', 0.06); for (let k = 0; k < 16; k++) P(k, 8, [190, 190, 190]); break;
      case 'metal': fillN('#9aa0a6', 0.12); for (const [x, y] of [[2, 2], [13, 2], [2, 13], [13, 13]]) P(x, y, [70, 74, 80]); break;
      case 'cockpit': clear(); for (let k = 0; k < 16; k++) { P(k, 0, [60, 64, 70]); P(k, 15, [60, 64, 70]); P(0, k, [60, 64, 70]); P(15, k, [60, 64, 70]); P(7, k, [60, 64, 70]); }
        for (let k = 2; k < 6; k++) P(k, k - 1, [220, 245, 255], 255); P(10, 2, [220, 245, 255]); break;
      case 'rotor': fillN('#2a2c30', 0.15); break;
      case 'stairs_down': case 'stairs_up': { // a glowing stairwell seen from above: steps going down into the light
        const up = name === 'stairs_up', c = C(up ? '#40d0a0' : '#8a40d0');
        fillN('#3a3a44', 0.1);
        for (let k = 0; k < 6; k++) { const v = up ? 0.55 + k * 0.12 : 1.25 - k * 0.12; rect(2, 2 + k * 2, 13, 3 + k * 2, '#000'); for (let x = 2; x <= 13; x++) for (let y = 2 + k * 2; y <= 3 + k * 2; y++) P(x, y, V(c, v)); }
        for (let k = 0; k < 16; k++) { P(k, 0, [200, 200, 220]); P(k, 15, [200, 200, 220]); P(0, k, [200, 200, 220]); P(15, k, [200, 200, 220]); }
        break;
      }
      case 'stairs_side': fillN('#4a4a55', 0.15); for (let k = 0; k < 16; k++) { P(k, 7, [70, 70, 80]); P(k, 15, [70, 70, 80]); } rect(5, 3, 10, 5, '#b080f0'); break;
      case 'cat_skin': fillN('#e8964a', 0.12); for (let y = 1; y < 16; y += 4) for (let x = 0; x < 16; x++) if ((x + y) % 5 < 2) P(x, y, [190, 110, 40]); break;
      case 'cat_face': fillN('#e8964a', 0.1); rect(2, 0, 4, 2, '#c87a3a'); rect(11, 0, 13, 2, '#c87a3a'); rect(3, 6, 5, 7, '#60c040'); P(4, 6, [10, 10, 10]); rect(10, 6, 12, 7, '#60c040'); P(11, 6, [10, 10, 10]);
        rect(7, 9, 8, 10, '#f0a0b0'); P(6, 12, [60, 40, 30]); P(9, 12, [60, 40, 30]); for (const yy of [10, 11]) { P(1, yy, [250, 250, 250]); P(14, yy, [250, 250, 250]); } break;
      case 'dog_skin': fillN('#c8a070', 0.12); for (let k = 0; k < 6; k++) rect(r() * 12 | 0, r() * 12 | 0, (r() * 12 | 0) + 3, (r() * 12 | 0) + 2, '#8a6038', 0.1); break;
      case 'dog_face': fillN('#c8a070', 0.1); rect(0, 0, 3, 8, '#8a6038'); rect(12, 0, 15, 8, '#8a6038'); P(5, 5, [20, 20, 20]); P(10, 5, [20, 20, 20]); rect(5, 9, 10, 13, '#e0c8a0'); rect(7, 9, 8, 10, '#202020'); rect(7, 13, 8, 15, '#e05070'); break;
      case 'silver_ore': ore('#eef2f8'); break;
      case 'gem_ore': fillN('#7f7f7f', 0.22); for (const col of ['#e0115f', '#2ecc71', '#2f6fd8', '#9b59b6']) { const cx = 2 + (r() * 12 | 0), cy = 2 + (r() * 12 | 0); P(cx, cy, C(col)); P(cx + 1, cy, C(col)); P(cx, cy + 1, C(col)); P(cx + 1, cy + 1, V(C(col), 1.3)); } break;
      case 'gold_block': case 'silver_block': { const c = name === 'gold_block' ? '#f5d742' : '#d8dee6', e = name === 'gold_block' ? [184, 150, 28] : [150, 158, 170];
        fillN(c, 0.08); for (let k = 0; k < 16; k++) { P(k, 0, e); P(k, 15, e); P(0, k, e); P(15, k, e); } rect(3, 3, 5, 4, '#ffffff'); P(10, 9, [255, 255, 255]); break; }
      case 'ice_bricks': fillN('#a8d8f0', 0.08); for (let k = 0; k < 16; k++) { P(k, 7, [120, 170, 210]); P(k, 15, [120, 170, 210]); } for (let y = 0; y < 7; y++) P(7, y, [120, 170, 210]); for (let y = 8; y < 15; y++) P(15, y, [120, 170, 210]);
        for (let k = 1; k < 5; k++) P(k + 1, k, [230, 248, 255]); break;
      case 'treasure_front': case 'treasure_side': case 'treasure_top': {
        const c = C('#8a5a24');
        for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, (y % 4 === 3 ? 0.82 : 1) + (r() - 0.5) * 0.1));
        for (let k = 0; k < 16; k++) { P(k, 0, [245, 200, 60]); P(k, 15, [245, 200, 60]); P(0, k, [245, 200, 60]); P(15, k, [245, 200, 60]); }
        if (name !== 'treasure_top') for (let k = 1; k < 15; k++) P(k, 5, [245, 200, 60]); else { rect(7, 1, 8, 14, '#f5c83c'); }
        if (name === 'treasure_front') { rect(6, 4, 9, 8, '#f5d742'); P(7, 6, [60, 40, 10]); P(8, 6, [60, 40, 10]); P(4, 10, [224, 17, 95]); P(11, 10, [46, 204, 113]); }
        break;
      }
      default: {
        const w = E.WOOL.find(q => q[1] === name);
        if (w) { const c = C(w[2]); for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) P(x, y, V(c, ((x + y) % 4 === 0 ? 0.9 : 1) + (r() - 0.5) * 0.1)); }
        else fillN('#ff00ff', 0);
      }
    }
    ac.putImageData(img, (i % 16) * 16, (i >> 4) * 16);
  }
  E.TILES.forEach(drawTile);

  // ---------------------------------------------------------------- item icons (data URLs, 32x32)
  const iconCache = {};
  function iconFor(id) {
    if (iconCache[id]) return iconCache[id];
    const c = document.createElement('canvas'); c.width = c.height = 32;
    const x = c.getContext('2d'); x.imageSmoothingEnabled = false;
    const tileXY = tt => [(tt % 16) * 16, (tt >> 4) * 16];
    const flat = tt => { const [sx, sy] = tileXY(tt); x.drawImage(atlas, sx, sy, 16, 16, 0, 0, 32, 32); };
    if (id < 100) {
      const d = blk(id);
      if (['cross', 'torch', 'ladder'].includes(d.shape)) flat(d.tex[0]);
      else {
        const top = d.tex[0], side = d.tex.length === 6 ? d.tex[2] : d.tex[2];
        const draw = (tt, a, b, cc, dd, e, f, dark) => {
          const [sx, sy] = tileXY(tt);
          x.setTransform(a, b, cc, dd, e, f); x.drawImage(atlas, sx, sy, 16, 16, 0, 0, 16, 16);
          if (dark) { x.fillStyle = 'rgba(0,0,0,' + dark + ')'; x.fillRect(0, 0, 16, 16); }
        };
        draw(top, 1, 0.5, -1, 0.5, 16, 0, 0);
        draw(side, 1, 0.5, 0, 1, 0, 8, 0.25);
        draw(d.tex.length === 6 ? d.tex[4] : side, 1, -0.5, 0, 1, 16, 16, 0.42);
        x.setTransform(1, 0, 0, 1, 0, 0);
      }
    } else if (id === 115) {
      const [ax, ay] = tileXY(T.door_top), [bx, by] = tileXY(T.door_bottom);
      x.drawImage(atlas, ax, ay, 16, 16, 8, 0, 16, 16); x.drawImage(atlas, bx, by, 16, 16, 8, 16, 16, 16);
    } else drawItem(x, id);
    return (iconCache[id] = c.toDataURL());
  }
  function drawItem(x, id) {
    const P = (px, py, col) => { x.fillStyle = col; x.fillRect(px * 2, py * 2, 2, 2); };
    const line = (x0, y0, x1, y1, col, th) => {
      const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)) || 1;
      for (let i = 0; i <= n; i++) { const px = Math.round(x0 + (x1 - x0) * i / n), py = Math.round(y0 + (y1 - y0) * i / n); P(px, py, col); if (th) P(px + 1, py, col); }
    };
    const blob = (cx, cy, rx, ry, col, edge) => { for (let py = 0; py < 16; py++) for (let px = 0; px < 16; px++) { const q = ((px - cx) / rx) ** 2 + ((py - cy) / ry) ** 2; if (q < 1) P(px, py, q > 0.7 && edge ? edge : col); } };
    const it = ITEMS[id] || {};
    // treasure add-on icons
    const gem = (c, e) => { for (let py = 2; py <= 13; py++) for (let px = 2; px <= 13; px++) { const q = Math.abs(px - 7.5) * 1.1 + Math.abs(py - 8) * (py < 6 ? 2 : 0.9); if (q < 6.5) P(px, py, q > 5 ? e : c); } P(6, 5, '#fff'); P(5, 6, '#fff'); };
    const fruitCol = { 151: ['#f5d742', '#b8961c'], 152: ['#b8d84a', '#7a9a2a'], 153: ['#f39c12', '#c0700a'], 155: ['#d01030', '#900818'], 156: ['#7a3aa8', '#4a1a70'] };
    if (it.potion || id === 159) { // bottle
      blob(8, 10, 5, 4.5, '#cfe8f0', '#7a9aa8'); for (let py = 3; py <= 6; py++) { P(7, py, '#7a9aa8'); P(8, py, '#cfe8f0'); } P(7, 2, '#8a6a40'); P(8, 2, '#8a6a40');
      if (it.potion) { blob(8, 10.5, 4, 3.5, it.color, it.color); P(6, 9, '#ffffff'); }
      return;
    }
    if (it.armor != null) {
      const c = ['#e0e0e0', '#f5d742', '#5decf5'][it.tier], e = ['#9a9a9a', '#b8961c', '#1aa6b0'][it.tier];
      const shapes = [ // helmet, chestplate, leggings, boots
        [[3, 4, 12, 6], [3, 7, 4, 10], [11, 7, 12, 10]], [[2, 3, 5, 6], [10, 3, 13, 6], [4, 6, 11, 13]],
        [[4, 3, 11, 5], [4, 6, 6, 13], [9, 6, 11, 13]], [[3, 8, 6, 12], [9, 8, 12, 12], [2, 12, 6, 13], [9, 12, 13, 13]]][it.armor];
      for (const [x0, y0, x1, y1] of shapes) for (let py = y0; py <= y1; py++) for (let px = x0; px <= x1; px++) P(px, py, py === y1 || px === x0 ? e : c);
      return;
    }
    if (it.kit) { // little castle in the kit's colours
      const k = E.CASTLES[it.kit], wc = { fort: '#8a6234', stone: '#8a8a8a', royal: '#9a9aa8', palace: '#dccf96', ice: '#a8d8f0' }[it.kit];
      for (let py = 6; py <= 14; py++) for (let px = 1; px <= 14; px++) P(px, py, (px === 7 || px === 8) && py > 10 ? '#3a2a1a' : wc);
      for (const px of [1, 3, 5, 10, 12, 14]) P(px, 5, wc);
      for (let py = 2; py <= 5; py++) P(7, py, '#5a4a3a');
      const fl = E.blk(k.flag).color; P(8, 2, fl); P(9, 2, fl); P(8, 3, fl); P(9, 3, fl);
      if (it.kit === 'royal') { P(4, 8, '#f5d742'); P(11, 8, '#f5d742'); }
      return;
    }
    if (it.box != null) { // a gift box with a ribbon, and sparkles on the good ones
      for (let py = 6; py <= 14; py++) for (let px = 2; px <= 13; px++) P(px, py, py === 14 || px === 2 ? '#00000060' : it.color);
      for (let px = 1; px <= 14; px++) { P(px, 5, it.color); P(px, 6, it.color); }
      for (let py = 5; py <= 14; py++) { P(7, py, '#d02030'); P(8, py, '#d02030'); }
      for (let px = 1; px <= 14; px++) P(px, 9, '#d02030');
      P(5, 3, '#d02030'); P(6, 4, '#d02030'); P(9, 4, '#d02030'); P(10, 3, '#d02030');
      if (it.box >= 2) { P(1, 1, '#fff'); P(14, 2, '#fff'); P(13, 0, '#ffffaa'); }
      if (it.box === 3) { P(0, 4, '#fff'); P(15, 7, '#ffccff'); P(3, 0, '#ffccff'); }
      return;
    }
    if (it.pet) {
      if (it.pet === 'babydino') { blob(8, 9, 5, 6, '#f0ead0', '#c8bc90'); for (const [sx, sy] of [[6, 6], [10, 9], [7, 12]]) blob(sx, sy, 1.2, 1, '#5a6b3a'); return; }
      for (let py = 8; py <= 14; py++) for (let px = 2; px <= 13; px++) P(px, py, (px + py) % 3 === 0 ? '#8a6234' : '#b8945f');
      for (let a = 0; a <= 12; a++) { const ang = Math.PI * a / 12; P(Math.round(8 + Math.cos(ang) * 6), Math.round(8 - Math.sin(ang) * 5), '#8a6234'); }
      const c = it.pet === 'cat' ? '#e8964a' : '#c8a070'; blob(8, 7, 3, 2.5, c); P(7, 7, '#111'); P(9, 7, '#111');
      if (it.pet === 'cat') { P(5, 4, c); P(11, 4, c); } else { P(5, 6, '#8a6038'); P(11, 6, '#8a6038'); }
      return;
    }
    if (it.tool && id !== 143 && id !== 182) {
      const mc = ['#a8844f', '#8a8a8a', '#e0e0e0', '#5decf5'][it.tier - 1], dk = ['#6b5130', '#555555', '#9a9a9a', '#1aa6b0'][it.tier - 1];
      if (it.tool === 'sword') { line(5, 11, 13, 3, mc, true); P(13, 2, mc); line(3, 8, 8, 13, '#4a3220'); line(2, 14, 4, 12, '#6b5130', true); line(6, 11, 13, 4, dk); return; }
      line(3, 13, 10, 6, '#6b5130', true);
      if (it.tool === 'pick') { line(5, 3, 10, 3, mc, true); line(10, 3, 13, 6, mc, true); line(13, 6, 13, 11, mc, true); line(6, 4, 10, 4, dk); line(12, 7, 12, 10, dk); }
      else if (it.tool === 'axe') { for (let py = 2; py <= 7; py++) for (let px = 8; px <= 13; px++) if (px - 8 + (7 - py) < 9) P(px, py, mc); line(8, 7, 13, 2, dk); }
      else { blob(11.5, 4.5, 3, 3, mc, dk); }
      return;
    }
    switch (id) {
      case 100: line(3, 13, 12, 4, '#7a5a32', true); break;
      case 101: blob(8, 8, 5, 4.5, '#2a2a2a', '#111'); P(6, 6, '#555'); P(9, 7, '#555'); break;
      case 102: case 103: { const c = id === 102 ? '#d8d8d8' : '#f5d742', e = id === 102 ? '#9a9a9a' : '#b8961c'; for (let py = 6; py <= 10; py++) for (let px = 3 + (10 - py); px <= 13 - (10 - py) + 2; px++) P(px, py, py === 10 ? e : c); break; }
      case 104: for (let py = 0; py < 16; py++) for (let px = 0; px < 16; px++) { const q = Math.abs(px - 7.5) + Math.abs(py - 7.5); if (q < 6) P(px, py, q > 4.5 ? '#1aa6b0' : '#5decf5'); } P(6, 5, '#fff'); break;
      case 105: blob(8, 9.5, 5, 5, '#d42020', '#9a1010'); line(8, 2, 8, 4, '#6b4a26'); P(9, 3, '#3f8a28'); P(6, 7, '#ff8080'); break;
      case 106: case 107: blob(8, 8.5, 6, 4.5, id === 106 ? '#f0a0a0' : '#b56a3a', id === 106 ? '#d07070' : '#7a4422'); line(4, 7, 11, 7, id === 106 ? '#ffe0e0' : '#d8a070'); break;
      case 108: case 109: blob(8, 8.5, 6, 4.5, id === 108 ? '#c03030' : '#7a4422', id === 108 ? '#8a2020' : '#4a2a12'); P(6, 7, id === 108 ? '#fff' : '#a06030'); P(10, 9, id === 108 ? '#fff' : '#a06030'); break;
      case 110: blob(8, 8.5, 6, 4.5, '#7a8a3a', '#4a5a2a'); P(6, 7, '#4a5a2a'); P(10, 9, '#4a5a2a'); break;
      case 111: line(4, 12, 12, 4, '#e8e8e0', true); P(3, 12, '#e8e8e0'); P(4, 13, '#e8e8e0'); P(12, 3, '#e8e8e0'); P(13, 4, '#e8e8e0'); break;
      case 112: line(3, 13, 12, 4, '#8a6a40'); P(12, 4, '#aaa'); P(13, 3, '#aaa'); P(11, 3, '#aaa'); P(13, 5, '#aaa'); P(2, 12, '#eee'); P(3, 14, '#eee'); P(2, 14, '#eee'); break;
      case 113: for (let py = 8; py < 14; py++) for (let px = 8 - (py - 8); px < 8 + (py - 8); px++) P(px, py, Math.random() < 0.5 ? '#555' : '#888'); break;
      case 114: blob(8, 8, 6, 5, '#9a5a2a', '#6b3a18'); break;
      case 116: for (let px = 2; px < 14; px++) P(px, 8 + Math.round(Math.sin(px) * 1.5), '#eeeeee'); break;
      case 140: case 141: blob(8, 9, 5.5, 4.5, id === 140 ? '#d05050' : '#8a4a22', id === 140 ? '#902a2a' : '#5a2a10'); line(11, 6, 14, 2, '#f0f0e0', true); P(14, 1, '#f0f0e0'); P(13, 1, '#f0f0e0'); break;
      case 142: for (let py = 2; py <= 14; py++) { const w = Math.max(0, Math.round((py - 2) / 3)); for (let px = 8 - w; px <= 8 + w; px++) P(px, py, py > 11 ? '#c8b890' : '#f4f2e6'); } break;
      case 143: line(5, 11, 13, 3, '#f4f2e6', true); P(13, 2, '#f4f2e6'); line(6, 11, 13, 4, '#c8b890'); line(3, 8, 8, 13, '#5a6b3a'); line(2, 14, 4, 12, '#6b5130', true); break;
      case 146: for (let py = 6; py <= 10; py++) for (let px = 3 + (10 - py); px <= 13 - (10 - py) + 2; px++) P(px, py, py === 10 ? '#9aa4ae' : '#eef2f8'); break;
      case 147: gem('#e0115f', '#8a0a3a'); break;
      case 148: gem('#2ecc71', '#16804a'); break;
      case 149: gem('#2f6fd8', '#183c80'); break;
      case 150: gem('#a868d8', '#5a2a80'); break;
      case 151: case 152: case 153: case 155: case 156: {
        const [c, e] = fruitCol[id];
        if (id === 155) { blob(5.5, 11, 3, 3, c, e); blob(10.5, 11, 3, 3, c, e); line(5, 8, 8, 3, '#3f8a28'); line(10, 8, 8, 3, '#3f8a28'); }
        else if (id === 156) { for (const [gx, gy] of [[6, 6], [9, 6], [7.5, 8.5], [5, 9], [10, 9], [7.5, 11.5]]) blob(gx, gy, 1.9, 1.9, c, e); line(8, 2, 8, 4, '#3f8a28'); }
        else if (id === 152) { blob(8, 10.5, 5, 4.5, c, e); blob(8, 6, 3, 3, c, e); line(8, 1, 8, 3, '#6b4a26'); }
        else { blob(8, 9.5, 5, 5, c, e); line(8, 2, 8, 4, '#6b4a26'); P(9, 3, '#3f8a28'); P(6, 7, '#ffffff'); }
        break;
      }
      case 154: for (let a = 0; a <= 10; a++) { const ang = Math.PI * (0.15 + a / 14); const px = Math.round(8 + Math.cos(ang) * 6), py = Math.round(4 + Math.sin(ang) * 8); P(px, py, '#f5d742'); P(px, py - 1, '#f5d742'); P(px + 1, py - 1, '#c8a820'); } P(13, 5, '#6b4a26'); break;
      case 157: for (let py = 6; py <= 13; py++) for (let px = 2; px <= 13; px++) { const q = ((px - 7.5) / 6) ** 2 + ((py - 5) / 8) ** 2; if (q < 1 && py >= 6) P(px, py, q > 0.75 ? '#3f8a28' : q > 0.6 ? '#e8f0c0' : '#e8404a'); } P(6, 8, '#111'); P(9, 9, '#111'); P(7, 11, '#111'); break;
      case 182: line(5, 11, 13, 3, '#eef2f8', true); P(13, 2, '#eef2f8'); line(6, 11, 13, 4, '#9aa4ae'); line(3, 8, 8, 13, '#2f6fd8'); line(2, 14, 4, 12, '#6b5130', true); break;
      case 144: // airplane, seen from above
        for (let py = 1; py <= 14; py++) { P(7, py, '#d23a32'); P(8, py, '#d23a32'); }
        for (let px = 1; px <= 14; px++) { P(px, 6, '#e8e8e8'); P(px, 7, '#e8e8e8'); }
        P(1, 6, '#d23a32'); P(14, 6, '#d23a32'); P(1, 7, '#d23a32'); P(14, 7, '#d23a32');
        for (let px = 5; px <= 10; px++) P(px, 13, '#e8e8e8');
        P(7, 4, '#8fd0f0'); P(8, 4, '#8fd0f0'); P(6, 0, '#333'); P(9, 0, '#333'); P(7, 0, '#9aa0a6'); P(8, 0, '#9aa0a6'); break;
      case 145: // helicopter, side view
        line(1, 2, 14, 2, '#2a2c30'); P(7, 3, '#9aa0a6');
        blob(6, 7.5, 4.5, 3.5, '#f0c020', '#c89a10'); blob(4, 7, 2, 2, '#8fd0f0');
        line(10, 7, 14, 6, '#f0c020', true); line(14, 4, 14, 7, '#2a2c30');
        line(2, 13, 11, 13, '#333'); P(4, 12, '#333'); P(9, 12, '#333'); break;
      case 117: for (let a = 0; a <= 12; a++) { const ang = Math.PI * (0.5 + a / 24); P(Math.round(13 + Math.cos(ang) * 10), Math.round(13 - Math.sin(ang) * 10), '#7a5a32'); }
        line(3, 13, 13, 3, '#cccccc'); break;
      default: blob(8, 8, 5, 5, '#ff00ff');
    }
  }

  // ---------------------------------------------------------------- WebGL
  const gl = cv.getContext('webgl', { antialias: false, alpha: false, powerPreference: 'high-performance' });
  const glOK = !!gl;
  let uintExt = null, prog, flat, atlasTex, quadIBO, maxQuads, dynVBO;
  if (glOK) {
    uintExt = gl.getExtension('OES_element_index_uint');
    const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(s)); return s; };
    const mk = (vs, fs) => { const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(p); return p; };
    prog = mk(`attribute vec3 aP;attribute vec2 aT;attribute vec3 aL;
      uniform mat4 uM;uniform vec3 uCam;uniform float uDay;uniform vec2 uFog;uniform float uMin;
      varying vec2 vT;varying vec3 vC;varying float vF;
      void main(){gl_Position=uM*vec4(aP,1.0);vT=aT;
        vec3 c=max(vec3(aL.x*uDay),vec3(aL.y)*vec3(1.0,0.85,0.6));vC=max(c,vec3(uMin))*aL.z;
        vF=clamp((length(aP-uCam)-uFog.x)/(uFog.y-uFog.x),0.0,1.0);}`,
    `precision mediump float;uniform sampler2D uTex;uniform vec3 uFogC;uniform float uAlpha;uniform vec4 uTint;
      varying vec2 vT;varying vec3 vC;varying float vF;
      void main(){vec4 t=texture2D(uTex,vT);if(t.a<0.5)discard;vec3 c=mix(t.rgb*vC,uTint.rgb,uTint.a);
        gl_FragColor=vec4(mix(c,uFogC,vF),uAlpha);}`);
    prog.a = { p: gl.getAttribLocation(prog, 'aP'), t: gl.getAttribLocation(prog, 'aT'), l: gl.getAttribLocation(prog, 'aL') };
    prog.u = {}; ['uM', 'uCam', 'uDay', 'uFog', 'uTex', 'uFogC', 'uAlpha', 'uTint', 'uMin'].forEach(n => { prog.u[n] = gl.getUniformLocation(prog, n); });
    flat = mk('attribute vec3 aP;uniform mat4 uM;void main(){gl_Position=uM*vec4(aP,1.0);}',
      'precision mediump float;uniform vec4 uC;void main(){gl_FragColor=uC;}');
    flat.a = gl.getAttribLocation(flat, 'aP'); flat.uM = gl.getUniformLocation(flat, 'uM'); flat.uC = gl.getUniformLocation(flat, 'uC');
    atlasTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, atlasTex);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    maxQuads = uintExt ? 65536 : 16383;
    const idx = uintExt ? new Uint32Array(maxQuads * 6) : new Uint16Array(maxQuads * 6);
    for (let q = 0; q < maxQuads; q++) { const b = q * 4, o = q * 6; idx[o] = b; idx[o + 1] = b + 1; idx[o + 2] = b + 2; idx[o + 3] = b; idx[o + 4] = b + 2; idx[o + 5] = b + 3; }
    quadIBO = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, quadIBO); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
    dynVBO = gl.createBuffer();
  } else $('nogl').classList.remove('hidden');
  const IDX_TYPE = () => uintExt ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT;

  // ---------------------------------------------------------------- meshing
  // vertex: x y z u v sky torch shade
  const FACES = [
    [1, 0, 0, [1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1], 4, 0.8],
    [-1, 0, 0, [0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0], 5, 0.8],
    [0, 1, 0, [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0], 0, 1.0],
    [0, -1, 0, [0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1], 1, 0.5],
    [0, 0, 1, [0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1], 3, 0.65],
    [0, 0, -1, [1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0], 2, 0.65],
  ];
  const faceTile = (def, f) => { const tx = def.tex; if (tx.length === 6) return tx[FACES[f][4]]; return f === 2 ? tx[0] : f === 3 ? tx[1] : tx[2]; };
  const AO = [0.42, 0.6, 0.8, 1];
  const PAD = CS + 2, PAD2 = PAD * PAD;
  const pb = new Uint8Array(PAD2 * CH), ph = new Int16Array(PAD2);
  let VB = new Float32Array(8 * 4 * 30000), WB = new Float32Array(8 * 4 * 8000);
  let vn = 0, wn = 0;
  function grow(water) {
    if (water) { const n = new Float32Array(WB.length * 2); n.set(WB); WB = n; } else { const n = new Float32Array(VB.length * 2); n.set(VB); VB = n; }
  }
  function vert(water, x, y, z, u, v, s, tl, sh) {
    if (water) { if (wn + 8 > WB.length) grow(true); WB[wn++] = x; WB[wn++] = y; WB[wn++] = z; WB[wn++] = u; WB[wn++] = v; WB[wn++] = s; WB[wn++] = tl; WB[wn++] = sh; }
    else { if (vn + 8 > VB.length) grow(false); VB[vn++] = x; VB[vn++] = y; VB[vn++] = z; VB[vn++] = u; VB[vn++] = v; VB[vn++] = s; VB[vn++] = tl; VB[vn++] = sh; }
  }
  const tu = tt => (tt % 16) / 16, tv = tt => (tt >> 4) / 16, EPS = 0.0004, TS = 1 / 16 - EPS * 2;
  let torchList = [];
  function torchAt(x, y, z) {
    let best = 0;
    for (let i = 0; i < torchList.length; i += 3) {
      const dx = torchList[i] - x, dy = torchList[i + 1] - y, dz = torchList[i + 2] - z;
      const d2 = dx * dx + dy * dy + dz * dz; if (d2 > 64) continue;
      const l = 1 - Math.sqrt(d2) / 8; if (l > best) best = l;
    }
    return best;
  }
  const G = (x, y, z) => y < 0 ? 16 : y >= CH ? 0 : pb[y * PAD2 + (z + 1) * PAD + x + 1];
  const SKY = (x, y, z) => { if (y >= CH) return 1; const dd = ph[(z + 1) * PAD + x + 1] - y; return dd < 0 ? 1 : Math.max(0.04, 0.85 - dd * 0.14); };

  // box with face culling at block edges, uv taken from the box position inside the tile
  function emitBox(water, bx, by, bz, x0, y0, z0, x1, y1, z1, def, sky, tl, onlyFaces) {
    const lim = [x0, y0, z0, x1, y1, z1];
    for (let f = 0; f < 6; f++) {
      if (onlyFaces && !(onlyFaces & (1 << f))) continue;
      const F = FACES[f], nx = F[0], ny = F[1], nz = F[2];
      const onEdge = (nx === 1 && x1 >= 1) || (nx === -1 && x0 <= 0) || (ny === 1 && y1 >= 1) || (ny === -1 && y0 <= 0) || (nz === 1 && z1 >= 1) || (nz === -1 && z0 <= 0);
      if (onEdge && isOpaque[G(bx + nx, by + ny, bz + nz)]) continue;
      const tt = faceTile(def, f), u0 = tu(tt) + EPS, v0 = tv(tt) + EPS;
      for (let k = 0; k < 4; k++) {
        const cx = F[3][k * 3] ? x1 : x0, cy = F[3][k * 3 + 1] ? y1 : y0, cz = F[3][k * 3 + 2] ? z1 : z0;
        let u, v;
        if (nx === 1) { u = 1 - cz; v = 1 - cy; } else if (nx === -1) { u = cz; v = 1 - cy; }
        else if (nz === 1) { u = cx; v = 1 - cy; } else if (nz === -1) { u = 1 - cx; v = 1 - cy; }
        else if (ny === 1) { u = cx; v = cz; } else { u = cx; v = 1 - cz; }
        vert(water, CX0 + bx + cx, by + cy, CZ0 + bz + cz, u0 + u * TS, v0 + v * TS, sky, tl, F[5]);
      }
    }
  }
  let CX0 = 0, CZ0 = 0;
  function buildMesh(c) {
    const nb = [];
    for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) nb.push(world.chunks.get(E.key(c.cx + dx, c.cz + dz)));
    for (let z = -1; z <= CS; z++) for (let x = -1; x <= CS; x++) {
      const ncx = x < 0 ? -1 : x >= CS ? 1 : 0, ncz = z < 0 ? -1 : z >= CS ? 1 : 0, n = nb[(ncz + 1) * 3 + ncx + 1];
      const lx = x - ncx * CS, lz = z - ncz * CS, pi = (z + 1) * PAD + x + 1;
      ph[pi] = n.hm[lz * CS + lx];
      for (let y = 0; y < CH; y++) pb[y * PAD2 + pi] = n.b[(y * CS + lz) * CS + lx];
    }
    CX0 = c.cx * CS; CZ0 = c.cz * CS;
    torchList = [];
    for (const n of nb) for (let i = 0; i < n.torches.length; i += 3) {
      const tx = n.torches[i] + 0.5, tz = n.torches[i + 2] + 0.5;
      if (tx > CX0 - 8 && tx < CX0 + CS + 8 && tz > CZ0 - 8 && tz < CZ0 + CS + 8) torchList.push(tx, n.torches[i + 1] + 0.5, tz);
    }
    vn = 0; wn = 0;
    const mapPix = new Uint8ClampedArray(CS * CS * 4);
    for (let z = 0; z < CS; z++) for (let x = 0; x < CS; x++) {
      let topSeen = false;
      for (let y = CH - 1; y >= 0; y--) {
        const id = pb[y * PAD2 + (z + 1) * PAD + x + 1];
        if (!id) continue;
        const def = BLOCKS[id];
        if (!topSeen && def.shape !== 'cross' && def.shape !== 'torch') {
          topSeen = true;
          const col = hexRgb(def.color || '#888'), f = 0.7 + y / CH * 0.6, mi = (z * CS + x) * 4;
          mapPix[mi] = col[0] * f; mapPix[mi + 1] = col[1] * f; mapPix[mi + 2] = col[2] * f; mapPix[mi + 3] = 255;
        }
        const shape = def.shape;
        if (shape === 'cube') {
          for (let f = 0; f < 6; f++) {
            const F = FACES[f], nx = F[0], ny = F[1], nz = F[2], n = G(x + nx, y + ny, z + nz);
            if (isOpaque[n] || (n === id && def.cutout)) continue;
            const tt = faceTile(def, f), u0 = tu(tt) + EPS, v0 = tv(tt) + EPS;
            const lx = x + nx, ly = y + ny, lz = z + nz;
            const aos = [0, 0, 0, 0], skys = [0, 0, 0, 0], tls = [0, 0, 0, 0];
            for (let k = 0; k < 4; k++) {
              const ccx = F[3][k * 3], ccy = F[3][k * 3 + 1], ccz = F[3][k * 3 + 2];
              let ax, ay, az, bx2, by2, bz2;
              if (nx) { ax = 0; ay = ccy ? 1 : -1; az = 0; bx2 = 0; by2 = 0; bz2 = ccz ? 1 : -1; }
              else if (ny) { ax = ccx ? 1 : -1; ay = 0; az = 0; bx2 = 0; by2 = 0; bz2 = ccz ? 1 : -1; }
              else { ax = ccx ? 1 : -1; ay = 0; az = 0; bx2 = 0; by2 = ccy ? 1 : -1; bz2 = 0; }
              const o1 = isOpaque[G(lx + ax, ly + ay, lz + az)], o2 = isOpaque[G(lx + bx2, ly + by2, lz + bz2)];
              const o3 = isOpaque[G(lx + ax + bx2, ly + ay + by2, lz + az + bz2)];
              aos[k] = (o1 && o2) ? 0 : 3 - (o1 + o2 + o3);
              let s = SKY(lx, ly, lz), cnt = 1;
              if (!o1) { s += SKY(lx + ax, ly + ay, lz + az); cnt++; }
              if (!o2) { s += SKY(lx + bx2, ly + by2, lz + bz2); cnt++; }
              if (!o3 && !(o1 && o2)) { s += SKY(lx + ax + bx2, ly + ay + by2, lz + az + bz2); cnt++; }
              skys[k] = s / cnt;
              tls[k] = torchList.length ? torchAt(CX0 + x + ccx, y + ccy, CZ0 + z + ccz) : 0;
            }
            const flip = aos[0] + aos[2] < aos[1] + aos[3];
            for (let kk = 0; kk < 4; kk++) {
              const k = flip ? (kk + 1) % 4 : kk;
              const ccx = F[3][k * 3], ccy = F[3][k * 3 + 1], ccz = F[3][k * 3 + 2];
              const uu = (k === 0 || k === 3) ? 0 : 1, vv = (k === 0 || k === 1) ? 1 : 0;
              vert(false, CX0 + x + ccx, y + ccy, CZ0 + z + ccz, u0 + uu * TS, v0 + vv * TS, skys[k], tls[k], F[5] * AO[aos[k]]);
            }
          }
        } else if (shape === 'water') {
          const above = G(x, y + 1, z), topH = above === 10 ? 1 : 0.875;
          for (let f = 0; f < 6; f++) {
            const F = FACES[f], n = G(x + F[0], y + F[1], z + F[2]);
            if (n === 10 || isOpaque[n] || (f === 3 && n !== 0)) continue;
            const tt = def.tex[0], u0 = tu(tt) + EPS, v0 = tv(tt) + EPS, sky = SKY(x + F[0], y + F[1], z + F[2]);
            for (let k = 0; k < 4; k++) {
              const ccy = F[3][k * 3 + 1] ? topH : 0, uu = (k === 0 || k === 3) ? 0 : 1, vv = (k === 0 || k === 1) ? 1 : 0;
              vert(true, CX0 + x + F[3][k * 3], y + ccy, CZ0 + z + F[3][k * 3 + 2], u0 + uu * TS, v0 + vv * TS, sky, 0, F[5]);
            }
          }
        } else {
          const sky = SKY(x, y, z), tl = torchList.length ? torchAt(CX0 + x + 0.5, y + 0.5, CZ0 + z + 0.5) : 0;
          if (shape === 'cross') {
            const tt = def.tex[0], u0 = tu(tt) + EPS, v0 = tv(tt) + EPS, X = CX0 + x, Z = CZ0 + z;
            const quads = [[0.15, 0.15, 0.85, 0.85], [0.85, 0.85, 0.15, 0.15], [0.15, 0.85, 0.85, 0.15], [0.85, 0.15, 0.15, 0.85]];
            for (const q of quads) {
              vert(false, X + q[0], y, Z + q[1], u0, v0 + TS, sky, tl, 0.9); vert(false, X + q[2], y, Z + q[3], u0 + TS, v0 + TS, sky, tl, 0.9);
              vert(false, X + q[2], y + 1, Z + q[3], u0 + TS, v0, sky, tl, 0.9); vert(false, X + q[0], y + 1, Z + q[1], u0, v0, sky, tl, 0.9);
            }
          } else if (shape === 'torch') emitBox(false, x, y, z, 7 / 16, 0, 7 / 16, 9 / 16, 10 / 16, 9 / 16, def, 1, 1, 0);
          else if (shape === 'slab' || shape === 'bed') emitBox(false, x, y, z, 0, 0, 0, 1, def.h, 1, def, sky, tl, 0);
          else if (shape === 'cactus') emitBox(false, x, y, z, 1 / 16, 0, 1 / 16, 15 / 16, 1, 15 / 16, def, sky, tl, 0);
          else if (shape === 'door') {
            const thin = 3 / 16, alongX = (def.axis === 'x') !== def.open;
            if (alongX) emitBox(false, x, y, z, 0, 0, 0, 1, 1, thin, def, sky, tl, 0);
            else emitBox(false, x, y, z, 0, 0, 0, thin, 1, 1, def, sky, tl, 0);
          } else if (shape === 'ladder') {
            let any = false;
            const th = 1 / 16;
            if (isOpaque[G(x - 1, y, z)]) { emitBox(false, x, y, z, 0, 0, 0, th, 1, 1, def, sky, tl, 1); any = true; }
            if (isOpaque[G(x + 1, y, z)]) { emitBox(false, x, y, z, 1 - th, 0, 0, 1, 1, 1, def, sky, tl, 2); any = true; }
            if (isOpaque[G(x, y, z - 1)]) { emitBox(false, x, y, z, 0, 0, 0, 1, 1, th, def, sky, tl, 16); any = true; }
            if (isOpaque[G(x, y, z + 1)]) { emitBox(false, x, y, z, 0, 0, 1 - th, 1, 1, 1, def, sky, tl, 32); any = true; }
            if (!any) emitBox(false, x, y, z, 0, 0, 0, 1, 1, th, def, sky, tl, 0);
          }
        }
      }
    }
    // upload
    const old = chunkGL.get(E.key(c.cx, c.cz));
    const m = old || { cx: c.cx, cz: c.cz, vbo: gl.createBuffer(), wvbo: gl.createBuffer(), n: 0, wn: 0 };
    gl.bindBuffer(gl.ARRAY_BUFFER, m.vbo); gl.bufferData(gl.ARRAY_BUFFER, VB.subarray(0, vn), gl.STATIC_DRAW);
    gl.bindBuffer(gl.ARRAY_BUFFER, m.wvbo); gl.bufferData(gl.ARRAY_BUFFER, WB.subarray(0, wn), gl.STATIC_DRAW);
    m.n = Math.min(maxQuads, vn / 32) * 6; m.wn = Math.min(maxQuads, wn / 32) * 6;
    chunkGL.set(E.key(c.cx, c.cz), m);
    c.dirty = false;
    // map tile
    const mc = document.createElement('canvas'); mc.width = mc.height = CS;
    mc.getContext('2d').putImageData(new ImageData(mapPix, CS, CS), 0, 0);
    mapTiles.set(E.key(c.cx, c.cz), mc);
  }

  // ---------------------------------------------------------------- mob / player models
  const HUM = (faceT, headT, shirt, arms, pants, thin) => {
    const a = thin ? 0.12 : 0.25, l = thin ? 0.12 : 0.25;
    return [
      { b: [-0.25, 1.45, -0.25, 0.25, 1.95, 0.25], f: faceT, o: headT, head: true },
      { b: [-0.25, 0.7, -0.125, 0.25, 1.45, 0.125], f: shirt, o: shirt },
      { b: [-0.25 - a, 0.7, -a / 2, -0.25, 1.45, a / 2], f: arms, o: arms, py: 1.4, sw: 1, arm: true },
      { b: [0.25, 0.7, -a / 2, 0.25 + a, 1.45, a / 2], f: arms, o: arms, py: 1.4, sw: -1, arm: true },
      { b: [-0.25, 0, -l / 2, -0.25 + l, 0.7, l / 2], f: pants, o: pants, py: 0.7, sw: -1 },
      { b: [0.25 - l, 0, -l / 2, 0.25, 0.7, l / 2], f: pants, o: pants, py: 0.7, sw: 1 },
    ];
  };
  const QUAD = (bodyB, body, headB, faceT, headO, legH, lx, lz) => {
    const legs = [];
    [[-lx, lz, 1], [lx - 0.25, lz, -1], [-lx, -lz - 0.25, -1], [lx - 0.25, -lz - 0.25, 1]].forEach(([x0, z0, s]) => legs.push({ b: [x0, 0, z0, x0 + 0.25, legH, z0 + 0.25], f: body, o: body, py: legH, sw: s }));
    return [{ b: bodyB, f: body, o: body }, { b: headB, f: faceT, o: headO, head: true }].concat(legs);
  };
  const MODELS = {
    zombie: HUM(T.zombie_face, T.zombie_skin, T.zombie_shirt, T.zombie_skin, T.zombie_pants, false),
    skeleton: HUM(T.skeleton_face, T.bone, T.bone, T.bone, T.bone, true),
    player: HUM(T.player_face, T.player_hair, T.player_shirt, T.player_skin, T.player_pants, false),
    creeper: [{ b: [-0.25, 1.2, -0.25, 0.25, 1.7, 0.25], f: T.creeper_face, o: T.creeper_skin, head: true },
      { b: [-0.25, 0.45, -0.125, 0.25, 1.2, 0.125], f: T.creeper_skin, o: T.creeper_skin },
      { b: [-0.25, 0, 0.125, 0, 0.45, 0.375], f: T.creeper_skin, o: T.creeper_skin, py: 0.45, sw: 1 },
      { b: [0, 0, 0.125, 0.25, 0.45, 0.375], f: T.creeper_skin, o: T.creeper_skin, py: 0.45, sw: -1 },
      { b: [-0.25, 0, -0.375, 0, 0.45, -0.125], f: T.creeper_skin, o: T.creeper_skin, py: 0.45, sw: -1 },
      { b: [0, 0, -0.375, 0.25, 0.45, -0.125], f: T.creeper_skin, o: T.creeper_skin, py: 0.45, sw: 1 }],
    spider: (() => {
      const m = [{ b: [-0.45, 0.25, -0.9, 0.45, 0.85, -0.1], f: T.spider_body, o: T.spider_body },
        { b: [-0.3, 0.3, -0.1, 0.3, 0.7, 0.3], f: T.spider_body, o: T.spider_body },
        { b: [-0.3, 0.25, 0.3, 0.3, 0.75, 0.75], f: T.spider_face, o: T.spider_body, head: true }];
      for (let i = 0; i < 4; i++) { const z = 0.25 - i * 0.18; m.push({ b: [-1.0, 0.45, z - 0.05, -0.3, 0.55, z + 0.05], f: T.spider_body, o: T.spider_body, leg: (i % 2 ? 1 : -1) }); m.push({ b: [0.3, 0.45, z - 0.05, 1.0, 0.55, z + 0.05], f: T.spider_body, o: T.spider_body, leg: (i % 2 ? -1 : 1) }); }
      return m;
    })(),
    pig: QUAD([-0.3, 0.35, -0.45, 0.3, 0.85, 0.45], T.pig_skin, [-0.25, 0.4, 0.45, 0.25, 0.9, 0.85], T.pig_face, T.pig_skin, 0.35, 0.3, 0.2),
    cow: QUAD([-0.38, 0.6, -0.55, 0.38, 1.3, 0.55], T.cow_skin, [-0.25, 0.85, 0.55, 0.25, 1.35, 0.9], T.cow_face, T.cow_skin, 0.6, 0.38, 0.3),
    sheep: QUAD([-0.38, 0.55, -0.5, 0.38, 1.15, 0.5], T.sheep_wool, [-0.2, 0.85, 0.5, 0.2, 1.25, 0.85], T.sheep_face, T.sheep_wool, 0.55, 0.38, 0.25),
    cat: QUAD([-0.17, 0.22, -0.35, 0.17, 0.48, 0.3], T.cat_skin, [-0.16, 0.32, 0.28, 0.16, 0.62, 0.55], T.cat_face, T.cat_skin, 0.24, 0.17, 0.18)
      .concat([{ b: [-0.04, 0.4, -0.7, 0.04, 0.48, -0.35], f: T.cat_skin, o: T.cat_skin }]),
    dog: QUAD([-0.22, 0.38, -0.45, 0.22, 0.74, 0.35], T.dog_skin, [-0.18, 0.5, 0.33, 0.18, 0.86, 0.72], T.dog_face, T.dog_skin, 0.4, 0.22, 0.2)
      .concat([{ b: [-0.04, 0.62, -0.7, 0.04, 0.7, -0.45], f: T.dog_skin, o: T.dog_skin }]),
    trex: (() => { const S = T.trex_skin; return [
      { b: [-0.55, 1.4, -1.0, 0.55, 2.4, 0.9], f: S, o: S },
      { b: [-0.45, 2.1, 0.8, 0.45, 2.9, 2.0], f: T.trex_face, o: S, head: true },
      { b: [-0.4, 1.85, 0.9, 0.4, 2.1, 1.9], f: S, o: S },
      { b: [-0.3, 1.6, -2.6, 0.3, 2.2, -1.0], f: S, o: S },
      { b: [-0.15, 1.75, -3.6, 0.15, 2.05, -2.6], f: S, o: S },
      { b: [-0.55, 0, -0.3, -0.15, 1.5, 0.2], f: S, o: S, py: 1.5, sw: 1 },
      { b: [0.15, 0, -0.3, 0.55, 1.5, 0.2], f: S, o: S, py: 1.5, sw: -1 },
      { b: [-0.45, 1.55, 0.85, -0.3, 1.7, 1.25], f: S, o: S },
      { b: [0.3, 1.55, 0.85, 0.45, 1.7, 1.25], f: S, o: S }]; })(),
    raptor: (() => { const S = T.raptor_skin; return [
      { b: [-0.25, 0.7, -0.5, 0.25, 1.1, 0.4], f: S, o: S },
      { b: [-0.18, 1.0, 0.35, 0.18, 1.3, 0.85], f: T.raptor_face, o: S, head: true },
      { b: [-0.1, 0.8, -1.3, 0.1, 1.0, -0.5], f: S, o: S },
      { b: [-0.22, 0, -0.1, -0.07, 0.75, 0.08], f: S, o: S, py: 0.75, sw: 1 },
      { b: [0.07, 0, -0.1, 0.22, 0.75, 0.08], f: S, o: S, py: 0.75, sw: -1 }]; })(),
    trike: (() => { const S = T.trike_skin, H = T.bone; const m = [
      { b: [-0.65, 0.6, -1.0, 0.65, 1.5, 0.9], f: S, o: S },
      { b: [-0.45, 0.6, 0.9, 0.45, 1.3, 1.6], f: T.trike_face, o: S, head: true },
      { b: [-0.75, 0.9, 0.82, 0.75, 1.9, 0.97], f: S, o: S },
      { b: [-0.35, 1.15, 1.6, -0.25, 1.25, 2.15], f: H, o: H },
      { b: [0.25, 1.15, 1.6, 0.35, 1.25, 2.15], f: H, o: H },
      { b: [-0.05, 0.85, 1.6, 0.05, 0.95, 1.9], f: H, o: H },
      { b: [-0.2, 0.8, -1.8, 0.2, 1.1, -1.0], f: S, o: S }];
      [[-0.65, 0.5, 1], [0.35, 0.5, -1], [-0.65, -0.9, -1], [0.35, -0.9, 1]].forEach(([x, z, s]) => m.push({ b: [x, 0, z, x + 0.3, 0.65, z + 0.3], f: S, o: S, py: 0.65, sw: s }));
      return m; })(),
    brachio: (() => { const S = T.brachio_skin; const m = [
      { b: [-0.7, 1.6, -1.2, 0.7, 2.8, 1.0], f: S, o: S },
      { b: [-0.25, 2.4, 0.8, 0.25, 5.0, 1.3], f: S, o: S },
      { b: [-0.3, 4.8, 1.0, 0.3, 5.3, 1.8], f: T.brachio_face, o: S, head: true },
      { b: [-0.25, 1.9, -3.0, 0.25, 2.4, -1.2], f: S, o: S }];
      [[-0.7, 0.5, 1], [0.25, 0.5, -1], [-0.7, -1.1, -1], [0.25, -1.1, 1]].forEach(([x, z, s]) => m.push({ b: [x, 0, z, x + 0.45, 1.65, z + 0.45], f: S, o: S, py: 1.65, sw: s }));
      return m; })(),
  };
  const MB = new Float32Array(8 * 4 * 6 * 20);
  function drawModel(boxes, x, y, z, yaw, walk, pitch, tint, armsForward, scale) {
    const sky = world.skyAt(Math.floor(x), Math.floor(y + 1), Math.floor(z)), tl = world.torchLightAt(x, y + 1, z);
    let n = 0;
    const cy = Math.cos(yaw), sy = Math.sin(yaw), s = scale || 1;
    for (const bx of boxes) {
      let ang = 0, piv = 0;
      if (bx.sw) { ang = Math.sin(walk) * 0.7 * bx.sw; piv = bx.py; }
      if (bx.arm && armsForward) ang = -1.45 + Math.sin(walk) * 0.15 * bx.sw;
      if (bx.head && pitch) { ang = -pitch; piv = bx.b[1]; }
      const ca = Math.cos(ang), sa = Math.sin(ang), b = bx.b;
      for (let f = 0; f < 6; f++) {
        const F = FACES[f], tt = f === 4 ? bx.f : bx.o, u0 = tu(tt) + EPS, v0 = tv(tt) + EPS;
        for (let k = 0; k < 4; k++) {
          let lx = F[3][k * 3] ? b[3] : b[0], ly = F[3][k * 3 + 1] ? b[4] : b[1], lz = F[3][k * 3 + 2] ? b[5] : b[2];
          if (bx.leg) { lz += Math.sin(walk + bx.leg) * 0.12 * (Math.abs(lx) > 0.5 ? 1 : 0); }
          if (ang) { const yy = ly - piv, zz = lz; ly = piv + yy * ca - zz * sa; lz = yy * sa + zz * ca; }
          lx *= s; ly *= s; lz *= s;
          const wx = lx * cy + lz * sy, wz = -lx * sy + lz * cy;
          const uu = (k === 0 || k === 3) ? 0 : 1, vv = (k === 0 || k === 1) ? 1 : 0;
          MB[n++] = x + wx; MB[n++] = y + ly; MB[n++] = z + wz; MB[n++] = u0 + uu * TS; MB[n++] = v0 + vv * TS; MB[n++] = sky; MB[n++] = tl; MB[n++] = F[5];
        }
      }
    }
    gl.bindBuffer(gl.ARRAY_BUFFER, dynVBO); gl.bufferData(gl.ARRAY_BUFFER, MB.subarray(0, n), gl.DYNAMIC_DRAW);
    bindAttribs();
    gl.uniform4fv(prog.u.uTint, tint || [0, 0, 0, 0]);
    gl.drawElements(gl.TRIANGLES, n / 32 * 6, IDX_TYPE(), 0);
  }
  // ---------------------------------------------------------------- vehicle models (nose towards +z, drawn with yaw + PI like players)
  // spin: 'x' | 'y' | 'z' turns that box around its own centre (propeller, rotors)
  function vehicleBoxes(kind, paint, pilot) {
    const S = T['paint' + paint], W = T.plane_wing, M = T.metal, G = T.cockpit, R = T.rotor;
    const bx = (b, f, o, spin) => ({ b, f, o: o == null ? f : o, spin });
    let m, seat;
    if (kind === 'plane') {
      m = [bx([-0.45, 0.5, -2.2, 0.45, 1.3, 1.6], S), bx([-0.35, 0.6, 1.6, 0.35, 1.2, 2.0], M),
        bx([-0.95, 0.86, 2.02, 0.95, 0.94, 2.1], R, R, 'z'), bx([-0.38, 1.3, 0.0, 0.38, 1.8, 0.9], G),
        bx([-2.7, 0.95, 0.1, 2.7, 1.1, 1.1], W), bx([-2.7, 0.96, 0.1, -2.2, 1.11, 1.1], S), bx([2.2, 0.96, 0.1, 2.7, 1.11, 1.1], S),
        bx([-1.1, 1.1, -2.2, 1.1, 1.2, -1.6], W), bx([-0.06, 1.2, -2.2, 0.06, 2.0, -1.5], S),
        bx([-0.85, 0, 0.6, -0.55, 0.5, 0.9], R, M), bx([0.55, 0, 0.6, 0.85, 0.5, 0.9], R, M), bx([-0.1, 0.25, -2.0, 0.1, 0.5, -1.8], R, M)];
      seat = [0.15, 0.85];
    } else {
      m = [bx([-0.7, 0.4, -0.8, 0.7, 1.9, 1.0], S), bx([-0.65, 0.55, 1.0, 0.65, 1.8, 1.6], G),
        bx([-0.15, 1.1, -3.4, 0.15, 1.4, -0.8], S), bx([-0.05, 1.1, -3.7, 0.05, 2.0, -3.3], S),
        bx([0.08, 1.05, -3.75, 0.14, 2.05, -3.25], R, R, 'x'),
        bx([-0.08, 1.9, -0.08, 0.08, 2.2, 0.08], M),
        bx([-3.0, 2.2, -0.15, 3.0, 2.28, 0.15], R, R, 'y'), bx([-0.15, 2.2, -3.0, 0.15, 2.28, 3.0], R, R, 'y'),
        bx([-0.8, 0, -0.9, -0.62, 0.1, 1.2], M), bx([0.62, 0, -0.9, 0.8, 0.1, 1.2], M),
        bx([-0.72, 0.1, 0.4, -0.64, 0.4, 0.5], M), bx([0.64, 0.1, 0.4, 0.72, 0.4, 0.5], M)];
      seat = [0.65, 1.05];
    }
    if (pilot) { const y = seat[0], z = seat[1];
      m.push(bx([-0.22, y + 0.5, z - 0.22, 0.22, y + 0.94, z + 0.22], T.player_face, T.player_hair), bx([-0.25, y, z - 0.15, 0.25, y + 0.5, z + 0.15], T.player_shirt)); }
    return m;
  }
  const VB2 = new Float32Array(8 * 4 * 6 * 24);
  function drawVehicle(boxes, x, y, z, yaw, pitch, roll, spin) {
    const sky = world.skyAt(Math.floor(x), Math.floor(y + 1), Math.floor(z)), tl = world.torchLightAt(x, y + 1, z);
    const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch), cr = Math.cos(roll), sr = Math.sin(roll);
    const cs = Math.cos(spin), ss = Math.sin(spin), PY = 0.9; // tilt around the middle of the body
    let n = 0;
    for (const bx of boxes) {
      const b = bx.b, mx = (b[0] + b[3]) / 2, my = (b[1] + b[4]) / 2, mz = (b[2] + b[5]) / 2;
      for (let f = 0; f < 6; f++) {
        const F = FACES[f], tt = f === 4 ? bx.f : bx.o, u0 = tu(tt) + EPS, v0 = tv(tt) + EPS;
        for (let k = 0; k < 4; k++) {
          let lx = F[3][k * 3] ? b[3] : b[0], ly = F[3][k * 3 + 1] ? b[4] : b[1], lz = F[3][k * 3 + 2] ? b[5] : b[2];
          if (bx.spin === 'y') { const ax = lx - mx, az = lz - mz; lx = mx + ax * cs - az * ss; lz = mz + ax * ss + az * cs; }
          else if (bx.spin === 'z') { const ax = lx - mx, ay = ly - my; lx = mx + ax * cs - ay * ss; ly = my + ax * ss + ay * cs; }
          else if (bx.spin === 'x') { const ay = ly - my, az = lz - mz; ly = my + ay * cs - az * ss; lz = mz + ay * ss + az * cs; }
          ly -= PY;
          let ax = lx * cr - ly * sr, ay = lx * sr + ly * cr;                 // roll (around the nose axis)
          const by = ay * cp + lz * sp, bz = -ay * sp + lz * cp;               // pitch (nose up when > 0)
          const wx = ax * cy + bz * sy, wz = -ax * sy + bz * cy;               // yaw
          const uu = (k === 0 || k === 3) ? 0 : 1, vv = (k === 0 || k === 1) ? 1 : 0;
          VB2[n++] = x + wx; VB2[n++] = y + by + PY; VB2[n++] = z + wz; VB2[n++] = u0 + uu * TS; VB2[n++] = v0 + vv * TS; VB2[n++] = sky; VB2[n++] = tl; VB2[n++] = F[5];
        }
      }
    }
    gl.bindBuffer(gl.ARRAY_BUFFER, dynVBO); gl.bufferData(gl.ARRAY_BUFFER, VB2.subarray(0, n), gl.DYNAMIC_DRAW);
    bindAttribs();
    gl.uniform4fv(prog.u.uTint, [0, 0, 0, 0]);
    gl.drawElements(gl.TRIANGLES, n / 32 * 6, IDX_TYPE(), 0);
  }
  function bindAttribs() {
    gl.vertexAttribPointer(prog.a.p, 3, gl.FLOAT, false, 32, 0);
    gl.vertexAttribPointer(prog.a.t, 2, gl.FLOAT, false, 32, 12);
    gl.vertexAttribPointer(prog.a.l, 3, gl.FLOAT, false, 32, 20);
  }

  // ---------------------------------------------------------------- matrices
  function perspective(fov, asp, n, f) { const t = 1 / Math.tan(fov / 2), nf = 1 / (n - f); return [t / asp, 0, 0, 0, 0, t, 0, 0, 0, 0, (f + n) * nf, -1, 0, 0, 2 * f * n * nf, 0]; }
  function mul(a, b) { const o = new Array(16); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; o[c * 4 + r] = s; } return o; }
  function viewMat(yaw, pitch, ex, ey, ez) {
    const r = [Math.cos(yaw), 0, -Math.sin(yaw)];
    const f = [-Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), -Math.cos(yaw) * Math.cos(pitch)];
    const b = [-f[0], -f[1], -f[2]];
    const u = [b[1] * r[2] - b[2] * r[1], b[2] * r[0] - b[0] * r[2], b[0] * r[1] - b[1] * r[0]];
    return [r[0], u[0], b[0], 0, r[1], u[1], b[1], 0, r[2], u[2], b[2], 0,
      -(r[0] * ex + r[1] * ey + r[2] * ez), -(u[0] * ex + u[1] * ey + u[2] * ez), -(b[0] * ex + b[1] * ey + b[2] * ez), 1];
  }
  const fwd = () => [-Math.sin(pl.yaw) * Math.cos(pl.pitch), Math.sin(pl.pitch), -Math.cos(pl.yaw) * Math.cos(pl.pitch)];

  // ---------------------------------------------------------------- game state
  let world = null, sim = null, link = null, pl = null, session = null, state = 'menu';
  const chunkGL = new Map(), mapTiles = new Map();
  let inv = new Array(36).fill(null), sel = 0;
  let mobs = new Map(), arrows = [], tnts = [], others = new Map(), myId = 1, simTime = 0.1;
  let target = null, breakT = 0, breakKey = '', attackCool = 0, useCool = 0, hurtFlash = 0, shake = 0, toastT = 0;
  let moveSend = 0, saveTimer = 0, stepT = 0, groanT = 5, lastJumpTap = 0;
  let vehicles = new Map(), ride = null, vSend = 0, myName = 'You', inSafe = false, myPet = null;
  const SPIRAL = []; for (let dz = -8; dz <= 8; dz++) for (let dx = -8; dx <= 8; dx++) SPIRAL.push([dx, dz, dx * dx + dz * dz]);
  SPIRAL.sort((a, b) => a[2] - b[2]);

  function toast(msg) { $('toast').textContent = msg; toastT = 3; }
  function newPlayer(pos, mode) {
    return { x: pos.x, y: pos.y, z: pos.z, vx: 0, vy: 0, vz: 0, w: 0.6, h: 1.8, yaw: 0, pitch: -0.2, onGround: false, fly: false,
      hp: 20, food: 20, air: 10, fallY: null, spawn: null, fuel: 0, mode: mode || 'survival', dead: false,
      fx: {}, armor: [null, null, null, null] }; // fx: potion effects (seconds left); armor: helmet, chestplate, leggings, boots item ids
  }
  const creative = () => pl && pl.mode === 'creative';
  const FX_NAMES = { regen: 'REGENERATION', str: 'STRENGTH', speed: 'SPEED', jump: 'JUMP', shield: 'SHIELD', night: 'NIGHT VISION', water: 'WATER BREATHING' };
  const armorPts = () => pl.armor.reduce((a, id) => a + (id ? ITEMS[id].pts : 0), 0);
  const hasBoots = id => pl.armor[3] === id;
  function drink(it) { // potions and the golden apple
    const healer = H().cls === 'healer';
    if (it.heal) { pl.hp = Math.min(maxHp(), pl.hp + it.heal); }
    if (it.potion === 'heal') pl.hp = Math.min(maxHp(), pl.hp + (healer ? 16 : 8));
    const fx = it.potion && it.potion !== 'heal' ? it.potion : it.fx, secs = it.secs * (healer && it.potion ? 2 : 1);
    if (fx) { pl.fx[fx] = Math.max(pl.fx[fx] || 0, secs); toast(FX_NAMES[fx] + ' for ' + secs + ' seconds!'); }
    if (it.potion) earn('potion');
    else if (it.potion) toast('+4 hearts');
    hurtFlash = 0; lastStats = '';
  }
  // ---------------------------------------------------------------- hero: XP, levels, class, points, achievements, loot boxes
  // Kept with the player (saved in the single-player world, or on the family server with the bag).
  const H = () => pl.hero || (pl.hero = { xp: 0, lvl: 1, cls: '', pts: { str: 0, spd: 0, hp: 0 }, free: 0, ach: {}, cnt: {}, fruit: {}, castles: {}, deepest: 0 });
  const CLASSES = {
    warrior: { name: 'WARRIOR', about: 'Hits 50% harder and has 2 extra hearts' },
    archer: { name: 'ARCHER', about: 'Arrows do double damage, and half the time the arrow is free' },
    healer: { name: 'HEALER', about: 'Always heals slowly; potions heal double and last twice as long' },
    explorer: { name: 'EXPLORER', about: 'Runs 25% faster, falls of 8 blocks don\'t hurt, sees a little in the dark' },
  };
  const maxHp = () => { if (!pl) return 20; const h = H(); return 20 + h.pts.hp * 2 + (h.cls === 'warrior' ? 4 : 0); };
  const xpNeed = lvl => 20 + lvl * 15;
  const meleeBoost = () => (1 + H().pts.str * 0.1) * (H().cls === 'warrior' ? 1.5 : 1);
  const speedBoost = () => (1 + Math.min(10, H().pts.spd) * 0.06) * (H().cls === 'explorer' ? 1.25 : 1);
  function addXp(n) {
    const h = H(); if (!n) return;
    h.xp += n;
    while (h.xp >= xpNeed(h.lvl)) {
      h.xp -= xpNeed(h.lvl); h.lvl++; h.free++;
      pl.hp = maxHp(); lastStats = '';
      announce('LEVEL ' + h.lvl + '!', 'You got a point to spend. Tap HERO to make yourself stronger, faster or tougher.', '#55FF55');
      if (h.lvl === 3) { const pet = 194 + Math.floor(Math.random() * 3); giveDrops([[pet, 1]]); announce('A PET IS WAITING FOR YOU!', 'You got a ' + E.itemName(pet) + '. Tap it to meet your new best friend.', '#FFAA00'); }
      if (h.lvl === 5) earn('level5'); if (h.lvl === 10) earn('level10');
    }
    if (state === 'hero') renderHero();
  }
  // achievements: [title, how to get it, tier 0 bronze .. 3 legendary, the announcer's comment]
  const ACH = {
    first_blood: ['Monster Masher', 'Defeat your first monster', 0, 'One down. Several thousand to go.'],
    zombie10: ['Zombie Zapper', 'Defeat 10 zombies', 1, 'The zombies have started a support group about you.'],
    skeleton10: ['Bone Collector', 'Defeat 10 skeletons', 1, 'That is a lot of spare ribs.'],
    spider5: ['Web Wrecker', 'Defeat 5 spiders', 0, 'Eight legs were not enough to run away.'],
    kaboom: ['Kaboom Club', 'Get caught in an explosion', 0, 'Your eyebrows will grow back. Probably.'],
    dino_snack: ['Dino Snack', 'Get bitten by a T-Rex', 0, 'The T-Rex says you taste like chicken.'],
    trex_down: ['Bigger Teeth', 'Defeat a T-Rex', 2, 'Who is the top of the food chain now?'],
    shiny: ['Shiny!', 'Dig up your first diamond', 0, 'Ooooh. Shiny.'],
    jewel: ['Jewel Thief', 'Find a jewel', 1, 'Finders keepers.'],
    castle: ['Knock Knock', 'Find a castle', 0, 'Nobody was home. Their treasure is now your treasure.'],
    royal: ['Royal Visitor', 'Find a Royal Castle', 1, 'Please wipe your feet on the royal carpet.'],
    all_castles: ['Castle Collector', 'Visit all five kinds of castle', 2, 'You have seen more castles than most kings.'],
    kit: ['Home Sweet Castle', 'Build a castle from a kit', 2, 'Property values in this area just went up.'],
    fly: ['Up Up and Away', 'Fly an airplane or helicopter', 1, 'Please keep your arms inside the vehicle.'],
    potion: ['Bottoms Up', 'Drink a potion', 0, 'It tastes like purple. Or maybe green.'],
    boots: ['Shiny Shoes', 'Wear Diamond Boots', 1, 'Gravity is no longer your problem.'],
    full_diamond: ['Diamond Hero', 'Wear a full set of diamond armor', 2, 'You sparkle when you walk. It is a little distracting.'],
    oops: ['Oopsie Daisy', 'Get defeated for the first time', 0, 'It happens to the best of heroes. Get back up!'],
    gravity: ['Gravity Wins', 'Get hurt by a fall', 0, 'The ground was closer than it looked.'],
    level5: ['Getting Stronger', 'Reach level 5', 1, 'The monsters have started to whisper your name.'],
    level10: ['Hero of the Realm', 'Reach level 10', 2, 'Statues are being built in your honor. Small ones.'],
    pet: ['Best Friends', 'Get a pet', 1, 'Every hero needs a sidekick.'],
    pet_win: ['Good Pet!', 'Your pet defeats a monster', 0, 'Your pet would like a treat now, please.'],
    dungeon: ['Going Down', 'Enter the dungeon', 0, 'Welcome, crawler. Mind the monsters.'],
    safe: ['Phew!', 'Rest in a safe room', 0, 'Nothing can get you in here. Probably.'],
    boss: ['Floor Boss Down', 'Defeat a floor boss', 2, 'The crowd goes wild! Well, the bats do.'],
    floor3: ['Deeper and Deeper', 'Reach Floor 3', 1, 'It is getting darker. And smellier.'],
    floor5: ['Crawler Champion', 'Reach Floor 5', 3, 'Legends will be told about this. Mostly by you.'],
    box: ['Box Opener', 'Open a loot box', 0, 'What is inside? Prizes! Mostly.'],
    legendary: ['Legendary!', 'Open a Legendary Loot Box', 2, 'The rarest box of all. Show off a little.'],
    fruit: ['Fruit Salad', 'Eat 5 different kinds of fruit', 1, 'An apple a day keeps the zombies away.'],
  };
  const TIER_NAMES = ['BRONZE', 'SILVER', 'GOLD', 'LEGENDARY'], TIER_COL = ['#d0884a', '#d8dee6', '#f5d742', '#d080ff'], TIER_XP = [10, 25, 60, 150];
  function earn(id) {
    const h = H(), a = ACH[id];
    if (!a || h.ach[id] || !pl || pl.mode === 'creative') return;
    h.ach[id] = Date.now();
    announce('NEW ACHIEVEMENT: ' + a[0].toUpperCase() + '!', a[1] + '. ' + a[3] + '\nReward: ' + TIER_NAMES[a[2]] + ' Loot Box (tap it to open)', TIER_COL[a[2]]);
    giveDrops([[190 + a[2], 1]]); addXp(TIER_XP[a[2]]);
  }
  const count = (k, n) => { const h = H(); h.cnt[k] = (h.cnt[k] || 0) + (n || 1); return h.cnt[k]; };
  // the announcer: big banners at the top, one after another
  const announceQ = []; let announceT = 0;
  function announce(title, text, color) { announceQ.push([title, text, color]); if (announceQ.length === 1 && announceT <= 0) nextAnnounce(); }
  function nextAnnounce() {
    const a = announceQ[0], el = $('announce');
    if (!a) { el.classList.add('hidden'); return; }
    el.querySelector('b').textContent = a[0]; el.querySelector('b').style.color = a[2] || '#FFFF55';
    el.querySelector('span').textContent = a[1]; el.style.borderColor = a[2] || '#FFFF55';
    el.classList.remove('hidden'); announceT = 4.5; sfx('fanfare');
  }
  function tickAnnounce(dt) { if (announceT > 0) { announceT -= dt; if (announceT <= 0) { announceQ.shift(); nextAnnounce(); } } }
  // loot boxes: random prizes, better boxes give better prizes
  function openBox(tier) {
    const R = (a, b) => a + Math.floor(Math.random() * (b - a + 1)), pick = a => a[Math.floor(Math.random() * a.length)];
    const potion = () => 160 + R(0, 7), jewel = () => 147 + R(0, 3), fruit = () => pick([105, 152, 153, 154, 155, 156, 157]);
    const pools = [
      () => pick([[105, 3], [22, 8], [112, 8], [102, 3], [160, 1], [155, 3], [101, 5], [146, 2], [fruit(), 3]]),
      () => pick([[103, 4], [102, 6], [104, R(1, 2)], [potion(), 1], [jewel(), 1], [146, 4], [151, 1], [fruit(), 4]]),
      () => pick([[104, R(3, 5)], [jewel(), 2], [potion(), 2], [151, 1], [170 + R(0, 7), 1], [182, 1], [57, 1], [potion(), 1]]),
      () => pick([[104, 8], [jewel(), 3], [178 + R(0, 3), 1], [183 + R(0, 4), 1], [194 + R(0, 2), 1], [162, 2], [165, 2], [53, 2], [151, 3]]),
    ];
    const items = []; for (let i = 0; i < 2 + tier; i++) items.push(pools[tier]());
    if (tier === 3 && Math.random() < 0.5) items.push([194 + R(0, 2), 1]);
    if (tier >= 1 && Math.random() < 0.25) items.push(pools[tier - 1]());
    giveDrops(items);
    announce(TIER_NAMES[tier] + ' LOOT BOX!', 'You got: ' + items.map(([id, n]) => (n > 1 ? n + ' ' : '') + E.itemName(id)).join(', '), TIER_COL[tier]);
    addXp([5, 15, 40, 100][tier]); earn('box'); if (tier === 3) earn('legendary');
  }
  function wearArmor(i) { // put the bag slot's armor on (swapping with what was worn)
    const s = inv[i], it = s && ITEMS[s.id]; if (!it || it.armor == null) return false;
    const old = pl.armor[it.armor]; pl.armor[it.armor] = s.id; inv[i] = old ? { id: old, n: 1 } : null;
    sfx('door'); toast('Wearing ' + it.name + (s.id === 181 ? '\nNo more fall damage!' : '')); renderHotbar(); lastStats = '';
    if (pl.armor[3] === 181) earn('boots');
    if (pl.armor.every((a, k) => a === 178 + k)) earn('full_diamond');
    return true;
  }

  function clientOnChange(x, y, z, old, id) {
    const cx = Math.floor(x / CS), cz = Math.floor(z / CS), lx = x - cx * CS, lz = z - cz * CS;
    const big = blk(old).light || blk(id).light;
    for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
      if (!big && !((dx === 0 || (dx === -1 && lx === 0) || (dx === 1 && lx === CS - 1)) && (dz === 0 || (dz === -1 && lz === 0) || (dz === 1 && lz === CS - 1)))) continue;
      const c = world.chunks.get(E.key(cx + dx, cz + dz)); if (c) c.dirty = true;
    }
  }

  // ---------------------------------------------------------------- chunk loading
  let lastUnload = 0;
  function updateChunks(budget) {
    const pcx = Math.floor(pl.x / CS), pcz = Math.floor(pl.z / CS), R = cfg.dist, t0 = performance.now();
    let gens = 0;
    for (const [dx, dz, d2] of SPIRAL) {
      if (d2 > (R + 1.5) * (R + 1.5)) break;
      if (!world.chunks.has(E.key(pcx + dx, pcz + dz))) { world.chunk(pcx + dx, pcz + dz, true); if (++gens >= 2 || performance.now() - t0 > budget) break; }
    }
    for (const [dx, dz, d2] of SPIRAL) {
      if (d2 > (R + 0.5) * (R + 0.5)) break;
      if (performance.now() - t0 > budget) break;
      const c = world.chunks.get(E.key(pcx + dx, pcz + dz));
      if (!c || !c.dirty) continue;
      let ok = true;
      for (let a = -1; a <= 1 && ok; a++) for (let b = -1; b <= 1; b++) if (!world.chunks.has(E.key(c.cx + a, c.cz + b))) { ok = false; break; }
      if (ok) buildMesh(c);
    }
    const now = performance.now();
    if (now - lastUnload > 2000) {
      lastUnload = now;
      for (const [k, m] of chunkGL) if (Math.abs(m.cx - pcx) > R + 2 || Math.abs(m.cz - pcz) > R + 2) { gl.deleteBuffer(m.vbo); gl.deleteBuffer(m.wvbo); chunkGL.delete(k); const c = world.chunks.get(k); if (c) c.dirty = true; }
      for (const [k, c] of world.chunks) if (Math.abs(c.cx - pcx) > R + 4 || Math.abs(c.cz - pcz) > R + 4) world.chunks.delete(k);
    }
  }
  function nearReady() {
    const pcx = Math.floor(pl.x / CS), pcz = Math.floor(pl.z / CS);
    for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) if (!chunkGL.has(E.key(pcx + dx, pcz + dz))) return false;
    return true;
  }

  // ---------------------------------------------------------------- inventory helpers
  const maxDur = id => (ITEMS[id] && ITEMS[id].dur) || 0;
  function addItem(id, n) {
    const st = E.stackSize(id);
    for (let i = 0; i < 36 && n > 0; i++) { const s = inv[i]; if (s && s.id === id && s.n < st) { const k = Math.min(n, st - s.n); s.n += k; n -= k; } }
    for (let i = 0; i < 36 && n > 0; i++) if (!inv[i]) { const k = Math.min(n, st); inv[i] = { id, n: k }; if (maxDur(id)) inv[i].d = maxDur(id); n -= k; }
    renderHotbar(); if (state === 'inv') renderInv();
    return n;
  }
  const countItem = id => inv.reduce((a, s) => a + (s && s.id === id ? s.n : 0), 0);
  function removeItem(id, n) {
    for (let i = 35; i >= 0 && n > 0; i--) { const s = inv[i]; if (s && s.id === id) { const k = Math.min(n, s.n); s.n -= k; n -= k; if (!s.n) inv[i] = null; } }
    renderHotbar();
  }
  function useHeld() { const s = inv[sel]; if (!s || creative()) return; s.n--; if (s.n <= 0) inv[sel] = null; renderHotbar(); }
  function wearTool() {
    const s = inv[sel]; if (!s || !maxDur(s.id) || creative()) return;
    s.d = (s.d || maxDur(s.id)) - 1;
    if (s.d <= 0) { inv[sel] = null; sfx('tool'); toast(E.itemName(s.id) + ' broke!'); }
    renderHotbar();
  }
  function giveDrops(items) {
    const names = [];
    for (const [id, n] of items) { const left = addItem(id, n); if (left < n) names.push('+' + (n - left) + ' ' + E.itemName(id)); }
    if (names.length) { sfx('pop'); toast(names.join('\n')); }
  }

  // ---------------------------------------------------------------- HUD
  function slotHTML(s, extra) {
    if (!s) return '<div class="slot ' + (extra || '') + '"></div>';
    const md = maxDur(s.id), dur = md && s.d != null && s.d < md ? '<div class="dur" style="width:' + (80 * s.d / md) + '%;background:' + (s.d / md > 0.3 ? '#0c0' : '#e22') + '"></div>' : '';
    return '<div class="slot ' + (extra || '') + '"><img src="' + iconFor(s.id) + '" alt="">' + (s.n > 1 ? '<span class="n">' + s.n + '</span>' : '') + dur + '</div>';
  }
  function renderHotbar() {
    let h = '';
    for (let i = 0; i < 9; i++) h += slotHTML(inv[i], i === sel ? 'sel' : '').replace('class="slot', 'data-i="' + i + '" class="slot');
    $('hotbar').innerHTML = h;
  }
  $('hotbar').addEventListener('pointerdown', e => { const el = e.target.closest('.slot'); if (el) { sel = +el.dataset.i; renderHotbar(); showHeldName(); } e.stopPropagation(); });
  function showHeldName() { const s = inv[sel]; if (s) toast(E.itemName(s.id)); }
  let lastStats = '';
  function renderStats() {
    if (creative() || !pl) { if (lastStats !== 'c') { $('stats').style.visibility = 'hidden'; lastStats = 'c'; } return; }
    const hp = Math.ceil(pl.hp), fd = Math.ceil(pl.food), air = pl.air < 10 ? Math.ceil(pl.air) : -1;
    const ap = Math.ceil(armorPts() / 2);
    const mh = maxHp() / 2;
    const key = hp + ',' + fd + ',' + air + ',' + ap + ',' + mh; if (key === lastStats) return; lastStats = key;
    $('armorbar').textContent = ap ? '◆'.repeat(ap) : '';
    $('stats').style.visibility = 'visible';
    let h = ''; for (let i = 0; i < mh; i++) h += hp >= (i + 1) * 2 ? '♥' : hp === i * 2 + 1 ? '♡' : '<span style="opacity:.3">♥</span>';
    let f = ''; for (let i = 0; i < 10; i++) f += fd >= (i + 1) * 2 ? '●' : fd === i * 2 + 1 ? '◐' : '<span style="opacity:.3">●</span>';
    $('hearts').innerHTML = h; $('food').innerHTML = f;
    $('airbar').textContent = air >= 0 ? 'o'.repeat(air) : '';
  }

  // ---------------------------------------------------------------- inventory / crafting / chest UI
  let picked = -1, openChest = null, chestSlots = [], station = '';
  function nearBlock(id, r) {
    const bx = Math.floor(pl.x), by = Math.floor(pl.y), bz = Math.floor(pl.z);
    for (let y = -2; y <= 3; y++) for (let z = -r; z <= r; z++) for (let x = -r; x <= r; x++) if (world.peek(bx + x, by + y, bz + z) === id) return true;
    return false;
  }
  function openInv(st, chestKey) {
    station = st || ''; openChest = chestKey || null; picked = -1;
    if (!openChest) chestSlots = [];
    state = 'inv'; releasePointer();
    $('inv').classList.remove('hidden');
    renderInv();
  }
  function closeInv() {
    if (openChest) link.send({ t: 'chestSet', k: openChest, slots: chestSlots });
    openChest = null; $('inv').classList.add('hidden'); state = 'play'; renderHotbar();
  }
  function recipeOK(r) {
    for (const [id, n] of r.inp) if (countItem(id) < n) return false;
    if (r.st === 'table' && !(station === 'table' || nearBlock(19, 4))) return false;
    if (r.st === 'furnace') { if (!(station === 'furnace' || nearBlock(20, 4))) return false; if (pl.fuel < 1 && !Object.keys(E.FUEL).some(k => countItem(+k) > 0)) return false; }
    return true;
  }
  function craft(r) {
    if (!recipeOK(r)) return;
    if (r.st === 'furnace' && pl.fuel < 1) {
      const fk = Object.keys(E.FUEL).map(Number).sort((a, b) => E.FUEL[b] - E.FUEL[a]).find(k => countItem(k) > 0 && !r.inp.some(q => q[0] === k && countItem(k) <= q[1]));
      if (fk == null) { toast('Needs fuel (coal or wood)'); return; }
      removeItem(fk, 1); pl.fuel += E.FUEL[fk];
    }
    if (r.st === 'furnace') pl.fuel -= 1;
    for (const [id, n] of r.inp) removeItem(id, n);
    const left = addItem(r.out, r.n);
    if (left) toast('Bag is full!');
    sfx('craft'); renderInv();
  }
  function renderInv() {
    const cr = creative();
    $('creativeBox').classList.toggle('hidden', !cr);
    $('craftBox').classList.toggle('hidden', cr && !openChest ? true : !!openChest);
    $('chestBox').classList.toggle('hidden', !openChest);
    let g = '';
    for (let i = 9; i < 36; i++) g += slotHTML(inv[i], picked === i ? 'pick' : '').replace('class="slot', 'data-s="' + i + '" class="slot');
    $('invGrid').innerHTML = g;
    let h = '';
    for (let i = 0; i < 9; i++) h += slotHTML(inv[i], picked === i ? 'pick' : (i === sel ? 'sel' : '')).replace('class="slot', 'data-s="' + i + '" class="slot');
    h += '<div class="slot" data-trash="1" title="Throw away" style="border-color:#FF5555;color:#FF5555">&#10006;</div>';
    $('invHot').innerHTML = h;
    let a = '';
    for (let i = 0; i < 4; i++) a += slotHTML(pl.armor[i] ? { id: pl.armor[i], n: 1 } : null).replace('class="slot', 'data-a="' + i + '" title="' + ['Helmet', 'Chestplate', 'Leggings', 'Boots'][i] + '" class="slot');
    $('armorGrid').innerHTML = a;
    $('armorInfo').textContent = armorPts() ? 'Monsters hurt you ' + Math.round(Math.min(0.8, armorPts() * 0.04) * 100) + '% less' + (hasBoots(181) ? ', no fall damage' : '') : 'Tap armor in your bag, then a slot here';
    $('invHot').style.gridTemplateColumns = 'repeat(10,auto)';
    if (openChest) {
      let c = '';
      for (let i = 0; i < 27; i++) c += slotHTML(chestSlots[i], '').replace('class="slot', 'data-c="' + i + '" class="slot');
      $('chestGrid').innerHTML = c;
    }
    if (cr) {
      if (!$('palette').childElementCount) $('palette').innerHTML = E.allCreative.map(id => slotHTML({ id, n: 1 }).replace('class="slot', 'data-p="' + id + '" class="slot')).join('');
    } else if (!openChest) {
      $('craftTitle').textContent = station === 'table' ? 'CRAFTING TABLE' : station === 'furnace' ? 'FURNACE' : 'CRAFTING';
      const list = E.RECIPES.map((r, i) => ({ r, i, ok: recipeOK(r) })).sort((a, b) => (b.ok - a.ok));
      $('recipes').innerHTML = list.map(({ r, i, ok }) => '<button class="rec' + (ok ? '' : ' no') + '" data-r="' + i + '"><img src="' + iconFor(r.out) + '" alt=""><span>' +
        (r.n > 1 ? r.n + ' ' : '') + E.itemName(r.out) + '<br><span class="ing">' + r.inp.map(([id, n]) => n + ' ' + E.itemName(id)).join(' + ') +
        (r.st ? ' · ' + (r.st === 'table' ? 'crafting table' : 'furnace + fuel') : '') + '</span></span></button>').join('');
    }
  }
  $('inv').addEventListener('click', e => {
    const s = e.target.closest('[data-s],[data-c],[data-p],[data-r],[data-trash],[data-a]');
    if (!s) return;
    if (s.dataset.a != null) { // armor slot: wear the picked piece, or take this one off
      const ai = +s.dataset.a;
      if (picked >= 0 && inv[picked] && ITEMS[inv[picked].id] && ITEMS[inv[picked].id].armor != null) { wearArmor(picked); picked = -1; }
      else if (pl.armor[ai]) { const id = pl.armor[ai]; if (!addItem(id, 1)) { pl.armor[ai] = null; lastStats = ''; } else toast('Bag is full!'); }
      renderInv(); return;
    }
    if (s.dataset.r != null) { craft(E.RECIPES[+s.dataset.r]); return; }
    if (s.dataset.p != null) { const id = +s.dataset.p; inv[sel] = { id, n: E.stackSize(id) }; if (maxDur(id)) inv[sel].d = maxDur(id); $('itemName').textContent = E.itemName(id); renderInv(); renderHotbar(); return; }
    if (s.dataset.trash) { if (picked >= 0) { inv[picked] = null; picked = -1; renderInv(); } else $('itemName').textContent = 'Pick an item, then tap here to throw it away'; return; }
    if (s.dataset.c != null) { // chest -> bag
      const i = +s.dataset.c, it = chestSlots[i]; if (!it) return;
      const left = addItem(it.id, it.n);
      chestSlots[i] = left ? Object.assign({}, it, { n: left }) : null;
      renderInv(); return;
    }
    const i = +s.dataset.s;
    if (inv[i]) $('itemName').textContent = E.itemName(inv[i].id);
    if (openChest && inv[i]) { // bag -> chest
      const it = inv[i], st = E.stackSize(it.id);
      for (let k = 0; k < 27 && it.n > 0; k++) { const c = chestSlots[k]; if (c && c.id === it.id && c.n < st) { const m = Math.min(it.n, st - c.n); c.n += m; it.n -= m; } }
      for (let k = 0; k < 27 && it.n > 0; k++) if (!chestSlots[k]) { chestSlots[k] = Object.assign({}, it); it.n = 0; }
      if (it.n <= 0) inv[i] = null;
      renderInv(); renderHotbar(); return;
    }
    if (picked < 0) { if (inv[i]) picked = i; }
    else {
      if (picked !== i) {
        const a = inv[picked], b = inv[i];
        if (b && a && a.id === b.id && b.n < E.stackSize(b.id)) { const m = Math.min(a.n, E.stackSize(b.id) - b.n); b.n += m; a.n -= m; if (!a.n) inv[picked] = null; }
        else { inv[picked] = b; inv[i] = a; }
      }
      picked = -1;
    }
    renderInv(); renderHotbar();
  });
  $('invClose').onclick = closeInv;

  // ---------------------------------------------------------------- pets and the HERO screen
  function petTap(mo) {
    const name = mo.ex ? mo.ex[0] : 'Your pet', owner = mo.ex ? mo.ex[1] : '';
    toast((owner && owner.toLowerCase() !== myName.toLowerCase() && session.remote ? owner + "'s pet " : '') + name + ' loves you! ♥');
    tone(880, 1320, 0.15, 'sine', 0.08); tone(1320, 990, 0.15, 'sine', 0.06, 0.15);
  }
  function openHero() { if (state !== 'play') return; state = 'hero'; releasePointer(); $('hero').classList.remove('hidden'); renderHero(); }
  function closeHero() { state = 'play'; $('hero').classList.add('hidden'); }
  function renderHero() {
    const h = H(), need = xpNeed(h.lvl);
    $('hLevel').textContent = (session.remote ? myName.toUpperCase() : 'HERO') + ' · LEVEL ' + h.lvl + (h.cls ? ' ' + CLASSES[h.cls].name : '');
    $('hXp').style.width = Math.round(100 * h.xp / need) + '%'; $('hXpText').textContent = h.xp + ' / ' + need + ' XP';
    $('hClass').innerHTML = Object.entries(CLASSES).map(([k, c]) => '<button class="btn small' + (h.cls === k ? ' on' : '') + '" data-cls="' + k + '">' + c.name + '</button>').join('');
    $('hClassAbout').textContent = h.cls ? CLASSES[h.cls].about : 'Pick a class. You can change it any time.';
    $('hFree').textContent = h.free ? h.free + ' point' + (h.free > 1 ? 's' : '') + ' to spend:' : 'Level up to get points.';
    const st = [['str', 'STRENGTH', '+10% damage each'], ['spd', 'SPEED', '+6% speed each (up to 10)'], ['hp', 'HEALTH', '+1 heart each (up to 10)']];
    $('hStats').innerHTML = st.map(([k, n, about]) => '<div class="row"><span style="min-width:8em;text-align:left">' + n + ' ' + h.pts[k] + '</span><button class="btn small" data-pt="' + k + '"' +
      (h.free && !((k === 'spd' || k === 'hp') && h.pts[k] >= 10) ? '' : ' disabled style="opacity:.4"') + '>+</button><span class="sub" style="font-size:.8em">' + about + '</span></div>').join('');
    $('hPet').innerHTML = myPet ? 'Pet: <b>' + myPet.name + '</b> the ' + { cat: 'cat', dog: 'dog', babydino: 'baby dino' }[myPet.kind] + ', level ' + myPet.lvl : 'No pet yet. You get one at level 3, or find one in a loot box.';
    $('hPetRow').classList.toggle('hidden', !myPet);
    const got = Object.keys(h.ach).length;
    $('hAchTitle').textContent = 'ACHIEVEMENTS ' + got + ' / ' + Object.keys(ACH).length;
    $('hAch').innerHTML = Object.entries(ACH).map(([k, a]) => '<div class="ach' + (h.ach[k] ? ' got' : '') + '"><b style="color:' + (h.ach[k] ? TIER_COL[a[2]] : '#777') + '">' + (h.ach[k] ? '★ ' : '☆ ') + a[0] + '</b><br><span>' + a[1] + '</span></div>').join('');
  }
  $('hero').addEventListener('click', e => {
    const b = e.target.closest('[data-cls],[data-pt]'); if (!b) return;
    const h = H();
    if (b.dataset.cls) { h.cls = b.dataset.cls; lastStats = ''; pl.hp = Math.min(pl.hp, maxHp()); toast('You are now a ' + CLASSES[h.cls].name + '!'); sfx('craft'); }
    if (b.dataset.pt && h.free > 0) { h.pts[b.dataset.pt]++; h.free--; lastStats = ''; sfx('craft'); }
    renderHero();
  });
  $('hPetSave').onclick = () => { const n = $('hPetName').value.trim(); if (n) { link.send({ t: 'petname', name: n }); $('hPetName').value = ''; } };
  $('hClose').onclick = closeHero;
  $('bHero').addEventListener('click', openHero);

  // ---------------------------------------------------------------- map
  let mapZoom = 3;
  function drawMap() {
    const c = $('mapCv'), x = c.getContext('2d');
    x.imageSmoothingEnabled = false; x.fillStyle = '#000'; x.fillRect(0, 0, c.width, c.height);
    const s = mapZoom, cx = c.width / 2, cy = c.height / 2;
    for (const [k, t] of mapTiles) {
      const [a, b] = k.split(',').map(Number);
      const px = cx + (a * CS - pl.x) * s, py = cy + (b * CS - pl.z) * s;
      if (px > -CS * s && py > -CS * s && px < c.width && py < c.height) x.drawImage(t, px, py, CS * s, CS * s);
    }
    const mark = (wx, wz, col, label) => { const px = cx + (wx - pl.x) * s, py = cy + (wz - pl.z) * s; x.fillStyle = col; x.fillRect(px - 4, py - 4, 8, 8); x.fillStyle = '#fff'; x.font = '14px monospace'; x.fillText(label, px + 6, py + 4); };
    if (session && session.spawn) mark(session.spawn.x, session.spawn.z, '#55FFFF', 'spawn');
    if (pl.spawn) mark(pl.spawn.x, pl.spawn.z, '#FF5555', 'bed');
    for (const o of others.values()) mark(o.x, o.z, '#FFFF55', o.name);
    x.save(); x.translate(cx, cy); x.rotate(-pl.yaw);
    x.fillStyle = '#fff'; x.beginPath(); x.moveTo(0, -9); x.lineTo(6, 7); x.lineTo(0, 3); x.lineTo(-6, 7); x.closePath(); x.fill(); x.restore();
    x.fillStyle = '#FFFF55'; x.font = '15px monospace';
    x.fillText('X ' + Math.floor(pl.x) + '  Y ' + Math.floor(pl.y) + '  Z ' + Math.floor(pl.z) + '   N is up', 10, 20);
  }
  function openMap() { state = 'map'; releasePointer(); $('mapPanel').classList.remove('hidden'); drawMap(); }
  function closeMap() { state = 'play'; $('mapPanel').classList.add('hidden'); }
  $('mapClose').onclick = closeMap;
  $('mapIn').onclick = () => { mapZoom = Math.min(8, mapZoom + 1); drawMap(); };
  $('mapOut').onclick = () => { mapZoom = Math.max(1, mapZoom - 1); drawMap(); };

  // ---------------------------------------------------------------- input
  const keys = {};
  let stick = { id: null, x: 0, y: 0, ox: 0, oy: 0 }, look = { id: null, sx: 0, sy: 0, lx: 0, ly: 0, t0: 0, moved: false, hold: false };
  let jumpHeld = false, downHeld = false, attackHeld = false, useHeld2 = false, locked = false;
  const playing = () => state === 'play' && pl && !pl.dead;
  addEventListener('keydown', e => {
    if (e.target.tagName === 'INPUT') return;
    keys[e.code] = true;
    if (state === 'play') {
      if (e.code === 'Space') { const now = performance.now(); if (creative() && now - lastJumpTap < 300) { pl.fly = !pl.fly; toast(pl.fly ? 'Flying' : 'Walking'); } lastJumpTap = now; e.preventDefault(); }
      if (e.code === 'KeyF' && ride) leaveRide();
      if (e.code === 'KeyE') openInv();
      if (e.code === 'KeyM') openMap();
      if (e.code === 'KeyH') openHero();
      if (e.code === 'Escape' || e.code === 'KeyP') openPause();
      if (/^Digit[1-9]$/.test(e.code)) { sel = +e.code.slice(5) - 1; renderHotbar(); showHeldName(); }
    } else if (state === 'inv' && (e.code === 'KeyE' || e.code === 'Escape')) closeInv();
    else if (state === 'map' && (e.code === 'KeyM' || e.code === 'Escape')) closeMap();
    else if (state === 'hero' && (e.code === 'KeyH' || e.code === 'Escape')) closeHero();
    else if (state === 'pause' && e.code === 'Escape') resume();
  });
  addEventListener('keyup', e => { keys[e.code] = false; });
  function releasePointer() { if (document.pointerLockElement) document.exitPointerLock(); }
  document.addEventListener('pointerlockchange', () => { locked = document.pointerLockElement === cv; if (!locked && state === 'play' && !isTouch) openPause(); });
  cv.addEventListener('mousedown', e => {
    if (isTouch || !playing()) return;
    if (!locked) { cv.requestPointerLock(); return; }
    if (e.button === 0) { attackHeld = true; doAttackPress(); }
    if (e.button === 2) { useHeld2 = true; doUse(); useCool = 0.3; }
  });
  addEventListener('mouseup', e => { if (e.button === 0) { attackHeld = false; breakT = 0; } if (e.button === 2) useHeld2 = false; });
  cv.addEventListener('contextmenu', e => e.preventDefault());
  addEventListener('mousemove', e => { if (locked && playing()) { pl.yaw -= e.movementX * 0.0025; pl.pitch = clamp(pl.pitch - e.movementY * 0.0025, -1.55, 1.55); } });
  addEventListener('wheel', e => { if (state === 'play') { sel = (sel + (e.deltaY > 0 ? 1 : 8)) % 9; renderHotbar(); } }, { passive: true });

  // touch: left = move stick, right = look / tap / hold
  const tz = $('touch');
  tz.addEventListener('touchstart', e => {
    e.preventDefault(); audio();
    for (const t of e.changedTouches) {
      if (!playing()) continue;
      if (t.clientX < innerWidth * 0.4 && stick.id === null) {
        stick = { id: t.identifier, ox: t.clientX, oy: t.clientY, x: 0, y: 0 };
        const r = $('stickRing'); r.style.left = t.clientX + 'px'; r.style.top = t.clientY + 'px'; r.style.display = 'block'; $('stickKnob').style.transform = '';
      } else if (look.id === null) {
        look = { id: t.identifier, sx: t.clientX, sy: t.clientY, lx: t.clientX, ly: t.clientY, t0: performance.now(), moved: false, hold: false };
      }
    }
  }, { passive: false });
  tz.addEventListener('touchmove', e => {
    e.preventDefault();
    for (const t of e.changedTouches) {
      if (t.identifier === stick.id) {
        const R = Math.min(innerWidth, innerHeight) * 0.13;
        let dx = (t.clientX - stick.ox) / R, dy = (t.clientY - stick.oy) / R; const l = Math.hypot(dx, dy); if (l > 1) { dx /= l; dy /= l; }
        stick.x = dx; stick.y = dy; $('stickKnob').style.transform = 'translate(' + dx * R + 'px,' + dy * R + 'px)';
      } else if (t.identifier === look.id && pl) {
        const dx = t.clientX - look.lx, dy = t.clientY - look.ly; look.lx = t.clientX; look.ly = t.clientY;
        if (Math.hypot(t.clientX - look.sx, t.clientY - look.sy) > 12) look.moved = true;
        const sens = 4.2 / Math.max(innerWidth, innerHeight);
        pl.yaw -= dx * sens * 1.4; pl.pitch = clamp(pl.pitch - dy * sens * 1.4, -1.55, 1.55);
      }
    }
  }, { passive: false });
  const touchEnd = e => {
    for (const t of e.changedTouches) {
      if (t.identifier === stick.id) { stick = { id: null, x: 0, y: 0 }; $('stickRing').style.display = 'none'; }
      else if (t.identifier === look.id) {
        if (!look.moved && !look.hold && performance.now() - look.t0 < 300 && playing()) {
          if (target && target.mob) doAttackPress(); else doUse();
        }
        look = { id: null }; attackHeld = false; breakT = 0;
      }
    }
  };
  tz.addEventListener('touchend', touchEnd); tz.addEventListener('touchcancel', touchEnd);
  const holdBtn = (el, on, off) => {
    el.addEventListener('touchstart', e => { e.preventDefault(); el.classList.add('held'); on(); }, { passive: false });
    el.addEventListener('touchend', e => { e.preventDefault(); el.classList.remove('held'); off(); });
    el.addEventListener('touchcancel', () => { el.classList.remove('held'); off(); });
    el.addEventListener('mousedown', on); el.addEventListener('mouseup', off);
  };
  holdBtn($('bJump'), () => {
    jumpHeld = true; const now = performance.now();
    if (creative() && now - lastJumpTap < 300) { pl.fly = !pl.fly; toast(pl.fly ? 'Flying' : 'Walking'); }
    lastJumpTap = now;
  }, () => { jumpHeld = false; });
  holdBtn($('bDown'), () => { downHeld = true; }, () => { downHeld = false; });
  $('bFly').addEventListener('click', () => { if (creative()) { pl.fly = !pl.fly; toast(pl.fly ? 'Flying' : 'Walking'); } });
  $('bExit').addEventListener('click', () => { if (ride) leaveRide(); });
  $('bInv').addEventListener('click', () => { if (state === 'play') openInv(); });
  $('bMap').addEventListener('click', () => { if (state === 'play') openMap(); });
  $('bPause').addEventListener('click', () => { if (state === 'play') openPause(); });

  // ---------------------------------------------------------------- vehicles: getting in, flying, getting out
  const angDiff = (a, b) => { let d = a - b; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; };
  function groundBelow(x, y, z) { // feet height of the first thing to stand on below (x, y, z)
    const bx = Math.floor(x), bz = Math.floor(z);
    for (let yy = Math.min(CH, Math.floor(y)); yy > 0; yy--) { const b = world.get(bx, yy - 1, bz); if (isSolid[b] || b === 10) return yy; }
    return Math.floor(y);
  }
  let engine = null;
  function engineSound(on, pitch) {
    const a = on ? audio() : null;
    if (!a) { if (engine) { try { engine.o.stop(); } catch (e) {} engine = null; } return; }
    if (!engine) {
      const o = a.createOscillator(), f = a.createBiquadFilter(), g = a.createGain();
      o.type = 'sawtooth'; f.type = 'lowpass'; f.frequency.value = 500; g.gain.value = 0.035;
      o.connect(f).connect(g).connect(a.destination); o.start(); engine = { o };
    }
    engine.o.frequency.setTargetAtTime(pitch, a.currentTime, 0.1);
  }
  function rideButtons() {
    $('bExit').classList.toggle('hidden', !ride);
    $('bJump').textContent = ride ? 'UP' : 'JUMP';
    $('bDown').classList.toggle('hidden', !ride && !creative()); $('bFly').classList.toggle('hidden', !!ride || !creative());
  }
  function startRide(m) {
    const d = E.VEHICLES[m.kind];
    ride = { id: m.id, kind: m.kind, x: m.x, y: m.y, z: m.z, vx: 0, vy: 0, vz: 0, w: d.w, h: d.h, yaw: m.yaw, pitch: 0, roll: 0, speed: 0, fall: 0, spin: 0 };
    pl.fly = false; pl.yaw = m.yaw; pl.pitch = 0;
    rideButtons(); sfx('door'); earn('fly');
    toast(m.kind === 'plane' ? 'Push forward to speed up, look up to take off!\nOUT to get out' : 'UP to fly up, move to fly around\nOUT to get out');
  }
  function leaveRide() {
    if (!ride) return;
    const r = ride, gy = groundBelow(r.x, r.y + 0.5, r.z);
    link.send({ t: 'vleave', id: r.id, x: +r.x.toFixed(2), y: gy, z: +r.z.toFixed(2) });
    // step out beside it, on the ground (falling out of the sky is no fun)
    const sx = r.x + Math.cos(r.yaw) * 2.6, sz = r.z - Math.sin(r.yaw) * 2.6, sgy = groundBelow(sx, gy + 4, sz);
    const free = !isSolid[world.get(Math.floor(sx), sgy, Math.floor(sz))] && !isSolid[world.get(Math.floor(sx), sgy + 1, Math.floor(sz))];
    Object.assign(pl, free ? { x: sx, y: sgy, z: sz } : { x: r.x, y: gy + 2.2, z: r.z }, { vx: 0, vy: 0, vz: 0, fallY: null });
    ride = null; engineSound(false); rideButtons(); sfx('door');
  }
  function updateRide(dt) {
    const r = ride;
    let mf = 0, ms = 0;
    if (keys.KeyW || keys.ArrowUp) mf += 1; if (keys.KeyS || keys.ArrowDown) mf -= 1;
    if (keys.KeyD || keys.ArrowRight) ms += 1; if (keys.KeyA || keys.ArrowLeft) ms -= 1;
    if (stick.id !== null) { mf -= stick.y; ms += stick.x; }
    const up = jumpHeld || keys.Space, down = downHeld || keys.ShiftLeft;
    if (r.kind === 'heli') {
      const sy = Math.sin(pl.yaw), cy = Math.cos(pl.yaw), sp = 12;
      const tx = (-sy * mf + cy * ms) * sp, tz = (-cy * mf - sy * ms) * sp, ty = up ? 7 : down ? -7 : 0;
      r.vx += (tx - r.vx) * Math.min(1, 2.5 * dt); r.vz += (tz - r.vz) * Math.min(1, 2.5 * dt); r.vy += (ty - r.vy) * Math.min(1, 4 * dt);
      r.yaw += angDiff(pl.yaw, r.yaw) * Math.min(1, 4 * dt);
      const fs = -r.vx * Math.sin(r.yaw) - r.vz * Math.cos(r.yaw), ss = r.vx * Math.cos(r.yaw) - r.vz * Math.sin(r.yaw);
      r.pitch += (-fs * 0.025 - r.pitch) * Math.min(1, 5 * dt); r.roll += (ss * 0.03 - r.roll) * Math.min(1, 5 * dt);
      r.spin += dt * 30;
      engineSound(true, 70 + Math.abs(r.vy) * 4 + Math.hypot(r.vx, r.vz) * 2);
    } else {
      // arcade plane: stick forward = faster, back = slower; look (or UP/DOWN) to climb and dive; stick sideways or look to turn
      if (mf > 0.2) r.speed += 9 * dt * mf; else if (mf < -0.2) r.speed += 14 * dt * mf;
      r.speed = clamp(r.speed, 0, 20);
      pl.yaw -= ms * 1.6 * dt;
      if (up || down) pl.pitch = clamp(pl.pitch + (up ? 1 : -1) * 1.2 * dt, -1.55, 1.55);
      const lift = clamp((r.speed - 6) / 6, 0, 1);
      let tp = clamp(pl.pitch, -0.7, 0.7);
      if (r.onGround && lift < 1) tp = Math.max(0, Math.min(tp, lift * 0.7));
      const turn = clamp(angDiff(pl.yaw, r.yaw), -1.8 * dt, 1.8 * dt);
      r.yaw += turn;
      r.roll += (clamp(-turn / dt * 0.45, -0.7, 0.7) * lift - r.roll) * Math.min(1, 4 * dt);
      r.pitch += (tp - r.pitch) * Math.min(1, 3 * dt);
      r.fall = lift >= 1 ? 0 : Math.min(14, r.fall + 16 * dt * (1 - lift));
      if (r.onGround) r.fall = Math.min(r.fall, 2);
      const cp = Math.cos(r.pitch);
      r.vx = -Math.sin(r.yaw) * cp * r.speed; r.vz = -Math.cos(r.yaw) * cp * r.speed;
      r.vy = Math.sin(r.pitch) * r.speed * lift - r.fall;
      r.spin += dt * (r.speed * 3 + 8);
      engineSound(true, 60 + r.speed * 6);
    }
    if (r.inWater) { r.vy = Math.max(r.vy, 2); r.speed *= 0.97; }
    const before = Math.hypot(r.vx, r.vz);
    E.moveBody(world, r, dt, true);
    if (r.hitWall && before > 6) { r.speed *= 0.3; sfx('hit', r.x, r.y, r.z); shake = 0.3; }
    if (r.y > CH + 40) { r.y = CH + 40; r.vy = Math.min(r.vy, 0); }
    if (r.y < 1) { r.y = groundBelow(r.x, CH, r.z); r.vy = 0; }
    Object.assign(pl, { x: r.x, y: r.y + 0.3, z: r.z, vx: 0, vy: 0, vz: 0, fallY: null, air: 10 });
  }

  // ---------------------------------------------------------------- player update
  function updatePlayer(dt) {
    if (pl.dead) return;
    for (const k in pl.fx) { pl.fx[k] -= dt; if (pl.fx[k] <= 0) { delete pl.fx[k]; toast(FX_NAMES[k] + ' wore off'); } }
    if ((pl.fx.regen || H().cls === 'healer' || inSafe) && !creative()) pl.hp = Math.min(maxHp(), pl.hp + dt * (pl.fx.regen || inSafe ? 0.8 : 0.15));
    if (ride) { updateRide(dt); return; }
    let mf = 0, ms = 0;
    if (keys.KeyW || keys.ArrowUp) mf += 1; if (keys.KeyS || keys.ArrowDown) mf -= 1;
    if (keys.KeyD || keys.ArrowRight) ms += 1; if (keys.KeyA || keys.ArrowLeft) ms -= 1;
    if (stick.id !== null) { mf -= stick.y; ms += stick.x; }
    const ml = Math.hypot(mf, ms); if (ml > 1) { mf /= ml; ms /= ml; }
    const sprint = (keys.ShiftLeft && !creative()) ? false : (ml > 0.95 && stick.id !== null) || keys.ControlLeft;
    const jump = jumpHeld || keys.Space, down = downHeld || keys.ShiftLeft;
    const sy = Math.sin(pl.yaw), cy = Math.cos(pl.yaw);
    const dirx = -sy * mf + cy * ms, dirz = -cy * mf - sy * ms;
    if (pl.fly) {
      const sp = 10;
      pl.vx = dirx * sp; pl.vz = dirz * sp; pl.vy = jump ? 8 : down ? -8 : 0;
      E.moveBody(world, pl, dt, false);
      if (pl.onGround && down) pl.fly = false;
      pl.fallY = null;
    } else {
      const sp = (pl.inWater ? 2.2 : sprint ? 5.6 : 4.3) * (pl.fx.speed ? 1.6 : 1) * speedBoost();
      const acc = pl.onGround ? 14 : pl.inWater ? 6 : 3;
      pl.vx += (dirx * sp - pl.vx) * Math.min(1, acc * dt);
      pl.vz += (dirz * sp - pl.vz) * Math.min(1, acc * dt);
      if (pl.inWater) { pl.vy -= 9 * dt; pl.vy = Math.max(pl.vy, -2.5); if (jump) pl.vy = Math.min(pl.vy + 22 * dt, 3.2); }
      else if (pl.onLadder) { pl.vy = jump || (ml > 0.1 && pl.hitWall) ? 2.6 : down ? -2.6 : Math.max(pl.vy - 28 * dt, -1.5); }
      else { pl.vy -= 28 * dt; if (jump && pl.onGround) pl.vy = pl.fx.jump ? 12.5 : 8.4; }
      pl.vy = Math.max(pl.vy, -40);
      const wasGround = pl.onGround;
      E.moveBody(world, pl, dt, true);
      if (pl.inWater || pl.onLadder) pl.fallY = null;
      else if (!pl.onGround) { if (pl.fallY === null || pl.y > pl.fallY) pl.fallY = pl.y; }
      else if (pl.fallY !== null) {
        const fall = pl.fallY - pl.y; pl.fallY = null;
        const safe = hasBoots(181) ? Infinity : pl.fx.jump ? 6.4 : H().cls === 'explorer' ? 8 : 3.4; // diamond boots: no fall damage
        if (fall > safe && !creative()) { hurt(Math.floor(fall - safe + 0.4), 'You fell from a high place'); earn('gravity'); }
        if (fall > 1) sfx('step');
      }
      if (pl.onGround && ml > 0.1) { stepT -= dt * (sprint ? 1.4 : 1); if (stepT <= 0) { stepT = 0.4; sfx('step'); } }
      if (!wasGround && pl.onGround && pl.inWater) sfx('splash');
    }
    if (pl.y < -10) { hurt(100, 'You fell out of the world'); }
    // survival needs
    if (!creative()) {
      pl.food = Math.max(0, pl.food - dt * (sprint ? 1 / 25 : 1 / 60) - (jump && pl.onGround ? 0.02 : 0));
      if (pl.food >= 16 && pl.hp < maxHp()) { pl.hp = Math.min(maxHp(), pl.hp + dt / 3); pl.food -= dt / 14; }
      if (pl.food <= 0 && pl.hp > 2) pl.hp -= dt / 4;
      const head = world.peek(Math.floor(pl.x), Math.floor(pl.y + 1.62), Math.floor(pl.z));
      if (head === 10 && !pl.fx.water) { pl.air -= dt; if (pl.air < 0) { pl.air = 0; pl.drown = (pl.drown || 0) + dt; if (pl.drown > 1) { pl.drown = 0; hurt(2, 'You drowned'); } } }
      else pl.air = Math.min(10, pl.air + dt * 4);
      if (pl.onGround && world.peek(Math.floor(pl.x), Math.floor(pl.y - 0.2), Math.floor(pl.z)) === 18) { pl.cactusT = (pl.cactusT || 0) - dt; if (pl.cactusT <= 0) { pl.cactusT = 0.6; hurt(1, 'You were pricked to death'); } }
    }
  }
  function hurt(n, why, mob) {
    if (creative() || pl.dead || n <= 0) return;
    if (mob) { // armor and the shield potion only help against monsters, arrows and explosions
      n *= 1 - Math.min(0.8, armorPts() * 0.04);
      if (pl.fx.shield) n *= 0.5;
      n = Math.max(0.5, n);
    }
    pl.hp -= n; hurtFlash = 0.5; sfx('hurt'); shake = 0.25;
    if (pl.hp <= 0) die(why);
  }
  function die(why) {
    leaveRide();
    pl.hp = 0; pl.dead = true; sfx('die'); earn('oops');
    $('deadWhy').textContent = why || ''; $('dead').classList.remove('hidden'); state = 'dead'; releasePointer();
    link.send({ t: 'move', x: pl.x, y: pl.y, z: pl.z, yaw: pl.yaw, pitch: pl.pitch, dead: true });
  }
  $('dRespawn').onclick = () => {
    const sp = pl.spawn || session.spawn;
    Object.assign(pl, { x: sp.x, y: sp.y + 0.1, z: sp.z, vx: 0, vy: 0, vz: 0, hp: maxHp(), food: 20, air: 10, dead: false, fallY: null, fx: {} });
    $('dead').classList.add('hidden'); state = 'play'; renderStats();
  };

  // ---------------------------------------------------------------- targeting, digging, placing
  function findTarget() {
    const eye = [pl.x, pl.y + 1.62, pl.z], f = fwd(), reach = creative() ? 6 : 5;
    const h = E.raycast(world, eye[0], eye[1], eye[2], f[0], f[1], f[2], reach, false);
    let best = h ? { block: h, d: h.d } : null;
    for (const m of mobs.values()) {
      const d = E.MOBS[E.MOB_TYPES[m.type]], hw = d.w / 2;
      const t = E.rayBox(eye[0], eye[1], eye[2], f[0], f[1], f[2], m.rx - hw, m.ry, m.rz - hw, m.rx + hw, m.ry + d.h, m.rz + hw);
      if (t >= 0 && t < 3.5 && (!best || t < best.d)) best = { mob: m, d: t };
    }
    for (const v of vehicles.values()) {
      if (v.rider && v.rider !== myId) continue;
      const d = E.VEHICLES[E.VEH_TYPES[v.kind]], hw = d.bw / 2;
      const t = E.rayBox(eye[0], eye[1], eye[2], f[0], f[1], f[2], v.rx - hw, v.ry, v.rz - hw, v.rx + hw, v.ry + d.bh, v.rz + hw);
      if (t >= 0 && t < 5 && (!best || t < best.d)) best = { veh: v, d: t };
    }
    target = best;
  }
  function breakTime(id) {
    const d = blk(id); if (d.hard < 0) return Infinity; if (creative()) return 0;
    const s = inv[sel], it = s && ITEMS[s.id];
    const can = !d.lvl || (it && it.tool === 'pick' && it.tier >= d.lvl);
    const speed = it && it.tool && it.tool === d.tool ? it.speed : 1;
    return d.hard * (can ? 1.5 : 5) / speed;
  }
  function doAttackPress() {
    if (target && target.mob && attackCool <= 0) {
      const s = inv[sel], it = s && ITEMS[s.id], f = fwd(), l = Math.hypot(f[0], f[2]) || 1;
      const mt = E.MOB_TYPES[target.mob.type], md = E.MOBS[mt], base = md.base || mt, undead = base === 'zombie' || base === 'skeleton';
      if (md.pet) { petTap(target.mob); attackCool = 0.6; return; }
      const dmg = (it && it.dmg ? it.dmg : 1) * (it && it.undead && undead ? it.undead : 1) * (pl.fx.str ? 2 : 1) * meleeBoost();
      link.send({ t: 'hit', id: target.mob.id, dmg, kx: f[0] / l, kz: f[2] / l });
      sfx('hit', target.mob.rx, target.mob.ry, target.mob.rz); attackCool = 0.45; if (it && it.tool) wearTool();
    }
  }
  function breakBlock(b) {
    const d = blk(b.id), s = inv[sel], it = s && ITEMS[s.id];
    const can = !d.lvl || (it && it.tool === 'pick' && it.tier >= d.lvl);
    if (notMine(b.x, b.z)) return;
    setBlock(b.x, b.y, b.z, 0);
    if (d.shape === 'door') { const oy = d.half ? b.y - 1 : b.y + 1; if (blk(world.peek(b.x, oy, b.z)).shape === 'door') setBlock(b.x, oy, b.z, 0); }
    sfx('break', b.x, b.y, b.z);
    if (!creative()) {
      if (can) {
        let drop = d.drop;
        if (d.shape === 'door') drop = 115;
        if (drop === -1) { // leaves: sometimes fruit or a stick
          const r = Math.random();
          drop = r < 0.04 ? 105 : r < 0.055 ? 152 : r < 0.07 ? 155 : r < 0.08 ? 156 : r < 0.13 ? 100 : 0;
        }
        if (drop === -2) drop = 147 + Math.floor(Math.random() * 4); // jewel ore: ruby, emerald, sapphire or amethyst
        if (drop) giveDrops([[drop, 1]]);
        const oreXp = { 12: 1, 13: 2, 14: 3, 15: 5, 55: 3, 56: 6 }[b.id];
        if (oreXp) addXp(oreXp);
        if (b.id === 15) earn('shiny'); if (b.id === 56) earn('jewel');
      }
      if (it && it.tool) wearTool();
    }
  }
  function setBlock(x, y, z, id) { world.set(x, y, z, id); link.send({ t: 'block', x, y, z, id }); }
  // kit castles belong to whoever built them: others can't change their blocks (the server checks this too)
  let owned = [];
  const castleOwnerAt = (x, z) => { for (const c of owned) if (x >= c.x0 && x < c.x0 + E.CW && z >= c.z0 && z < c.z0 + E.CW) return c; return null; };
  function notMine(x, z) {
    const c = castleOwnerAt(x, z);
    if (!c || c.owner.toLowerCase() === myName.toLowerCase()) return false;
    toast('This is ' + c.owner + "'s castle"); return true;
  }
  function doUse() {
    if (!playing() || useCool > 0 || ride) return;
    useCool = 0.22;
    const s = inv[sel], it = s && ITEMS[s.id];
    if (target && target.mob) { doAttackPress(); return; }
    if (target && target.veh) {
      const v = target.veh;
      if (v.owner && v.owner.toLowerCase() !== myName.toLowerCase()) toast('This is ' + v.owner + "'s " + (E.VEH_TYPES[v.kind] === 'plane' ? 'airplane' : 'helicopter') + '.\nMake your own at a crafting table!');
      else link.send({ t: 'vride', id: v.id });
      useCool = 0.6; return;
    }
    // airplane / helicopter: set it down on the block you tap (always uses up the item, even in creative)
    if (it && it.vehicle && target && target.block) {
      const b = target.block;
      let px = b.x + b.nx + 0.5, pz = b.z + b.nz + 0.5, py = b.ny === 1 ? b.y + blk(b.id).h : b.y + b.ny;
      if (Math.hypot(px - pl.x, pz - pl.z) < 2.5) { // too close: put it down a few steps ahead instead
        px = pl.x - Math.sin(pl.yaw) * 3.5; pz = pl.z - Math.cos(pl.yaw) * 3.5; py = groundBelow(px, pl.y + 3, pz);
      }
      const vd = E.VEHICLES[it.vehicle], hw = vd.w / 2 + 0.3;
      for (let y = Math.floor(py); y <= Math.floor(py + vd.h + 0.5); y++) for (let z = Math.floor(pz - hw); z <= Math.floor(pz + hw); z++) for (let x = Math.floor(px - hw); x <= Math.floor(px + hw); x++)
        if (isSolid[world.peek(x, y, z)]) { toast('Not enough room here.\nFind an open spot!'); return; }
      link.send({ t: 'vplace', kind: it.vehicle, x: px, y: py, z: pz, yaw: pl.yaw });
      s.n--; if (s.n <= 0) inv[sel] = null; renderHotbar();
      sfx('place', px, py, pz); toast('Tap your ' + (it.vehicle === 'plane' ? 'airplane' : 'helicopter') + ' to get in!\nHold on it to pick it back up');
      useCool = 0.6; return;
    }
    // loot box: tap to open
    if (it && it.box != null) { s.n--; if (s.n <= 0) inv[sel] = null; renderHotbar(); openBox(it.box); useCool = 0.8; return; }
    // pet basket / egg: call your pet (it stays yours; a different one swaps)
    if (it && it.pet) { link.send({ t: 'pet', item: s.id }); inv[sel] = null; renderHotbar(); sfx('pop'); useCool = 1; return; }
    // castle kit: the castle builds itself with its gate 4 steps in front of you (always uses up the kit)
    if (it && it.kit) {
      const f = fwd(), l = Math.hypot(f[0], f[2]) || 1, dx = f[0] / l, dz = f[2] / l, k = 13 / Math.max(Math.abs(dx), Math.abs(dz));
      const cx = Math.floor(pl.x + dx * k), cz = Math.floor(pl.z + dz * k); // far enough that you are outside its walls
      link.send({ t: 'kit', item: s.id, x: cx, z: cz });
      inv[sel] = null; renderHotbar(); sfx('craft'); shake = 0.4; useCool = 1; return;
    }
    // bow
    if (it && it.bow) {
      const archer = H().cls === 'archer';
      if (!creative() && countItem(112) < 1) { toast('You need arrows'); return; }
      if (!creative() && !(archer && Math.random() < 0.5)) removeItem(112, 1);
      const f = fwd();
      link.send({ t: 'shoot', x: pl.x + f[0] * 0.5, y: pl.y + 1.5 + f[1] * 0.5, z: pl.z + f[2] * 0.5, vx: f[0] * 32, vy: f[1] * 32, vz: f[2] * 32,
        dmg: 6 * (archer ? 2 : 1) * (1 + H().pts.str * 0.05) });
      sfx('bow'); wearTool(); useCool = 0.6; return;
    }
    const b = target && target.block;
    if (b) {
      const id = b.id, d = blk(id);
      if (id === 19) { openInv('table'); return; }
      if (id === 20) { openInv('furnace'); return; }
      if (E.isChest(id)) { const k = b.x + ',' + b.y + ',' + b.z; openChestAt(k); return; }
      if (id === 61 || id === 62) { link.send({ t: 'stairs', x: b.x, y: b.y, z: b.z }); useCool = 1.5; return; }
      if (d.shape === 'door') {
        const ny = d.open ? id - 4 : id + 4, oy = d.half ? b.y - 1 : b.y + 1, oid = world.peek(b.x, oy, b.z);
        setBlock(b.x, b.y, b.z, ny);
        if (blk(oid).shape === 'door') setBlock(b.x, oy, b.z, blk(oid).open ? oid - 4 : oid + 4);
        sfx('door', b.x, b.y, b.z); return;
      }
      if (id === 31) { link.send({ t: 'sleep' }); pl.spawn = { x: b.x + 0.5, y: b.y + 1, z: b.z + 0.5 }; toast('Spawn point set'); return; }
      if (id === 32) { link.send({ t: 'ignite', x: b.x, y: b.y, z: b.z }); sfx('hiss', b.x, b.y, b.z); return; }
    }
    // potions (the empty bottle comes back) and the golden apple
    if (it && (it.potion || it.fx)) {
      if (it.fx) pl.food = Math.min(20, pl.food + it.food);
      drink(it); s.n--; if (s.n <= 0) inv[sel] = null; if (it.potion) addItem(159, 1);
      renderHotbar(); sfx(it.potion ? 'drink' : 'eat'); return;
    }
    // armor: tap to wear it
    if (it && it.armor != null) { wearArmor(sel); return; }
    // eat
    if (it && it.food) {
      if (creative() || pl.food >= 20) { toast('You are not hungry'); return; }
      pl.food = Math.min(20, pl.food + it.food); useHeld(); sfx('eat');
      if ([105, 152, 153, 154, 155, 156, 157].includes(s.id)) { const h = H(); h.fruit[s.id] = 1; if (Object.keys(h.fruit).length >= 5) earn('fruit'); }
      return;
    }
    if (!s || !b) return;
    // place
    let place = s.id < 100 ? s.id : (it && it.places) || 0;
    if (!place) return;
    let px = b.x + b.nx, py = b.y + b.ny, pz = b.z + b.nz;
    if (b.id === 38 || b.id === 36 || b.id === 37) { px = b.x; py = b.y; pz = b.z; }
    const cur = world.peek(px, py, pz);
    if (cur && cur !== 10 && cur !== 38) return;
    if (py < 1 || py >= CH - 1) return;
    if (notMine(px, pz)) return;
    if (place === 23) {
      const f = fwd(); place = Math.abs(f[0]) > Math.abs(f[2]) ? 25 : 23;
      const above = world.peek(px, py + 1, pz);
      if (above && above !== 38) return;
      setBlock(px, py, pz, place); setBlock(px, py + 1, pz, place + 1); useHeld(); sfx('place', px, py, pz); return;
    }
    if (isSolid[place]) {
      const hw = pl.w / 2;
      if (px + 1 > pl.x - hw && px < pl.x + hw && pz + 1 > pl.z - hw && pz < pl.z + hw && py + 1 > pl.y && py < pl.y + pl.h) return;
      for (const o of others.values()) if (Math.abs(o.x - (px + 0.5)) < 0.8 && Math.abs(o.z - (pz + 0.5)) < 0.8 && py + 1 > o.y && py < o.y + 1.8) return;
    }
    if ((blk(place).shape === 'cross') && !isSolid[world.peek(px, py - 1, pz)]) return;
    setBlock(px, py, pz, place); useHeld(); sfx('place', px, py, pz);
  }
  function openChestAt(k) { openChest = k; chestSlots = []; link.send({ t: 'chest', k }); }
  function interact(dt) {
    attackCool -= dt; useCool -= dt;
    if (ride) { target = null; breakT = 0; return; }
    findTarget();
    const holding = attackHeld || (look.id != null && look.hold);
    if (look.id != null && !look.hold && !look.moved && performance.now() - look.t0 > 300) look.hold = true;
    if (holding && target && target.veh) { // hold on your own vehicle to put it back in the bag
      const v = target.veh;
      if (v.owner && v.owner.toLowerCase() !== myName.toLowerCase()) { if (breakT === 0) toast('Only ' + v.owner + ' can pick that up'); breakT = 0.001; return; }
      if (breakKey !== 'v' + v.id) { breakKey = 'v' + v.id; breakT = 0; }
      breakT += dt;
      if (breakT >= 1) { link.send({ t: 'vpickup', id: v.id }); vehicles.delete(v.id); breakT = 0; breakKey = ''; }
      return;
    }
    if (look.id != null && look.hold && target && target.mob) { doAttackPress(); return; }
    if (holding && target && target.block && creative()) {
      if (attackCool <= 0 && blk(target.block.id).hard >= 0) { breakBlock(target.block); attackCool = 0.25; }
    } else if (holding && target && target.block) {
      const b = target.block, k = b.x + ',' + b.y + ',' + b.z;
      if (k !== breakKey) { breakKey = k; breakT = 0; }
      const tt = breakTime(b.id);
      if (tt === Infinity) return;
      breakT += dt;
      if (Math.floor(breakT / 0.25) !== Math.floor((breakT - dt) / 0.25)) sfx('dig', b.x, b.y, b.z);
      if (breakT >= tt) { breakBlock(b); breakT = 0; breakKey = ''; }
    } else if (holding && target && target.mob) doAttackPress();
    else { breakT = 0; breakKey = ''; }
    if (useHeld2 && useCool <= 0) doUse();
  }

  // ---------------------------------------------------------------- networking
  function onMsg(m) {
    switch (m.t) {
      case 'welcome': cfg.signedIn = true; saveCfg(); retries = 0; startRemote(m); break;
      case 'error': $('status').textContent = m.text; cfg.signedIn = false; saveCfg(); updateMultiBtn(); showMpForm(); if (link && link.ws) link.ws.close(); break;
      case 'blocks': if (link && link.local) break; // single player shares the sim's world: already changed
        for (let i = 0; i < m.l.length; i += 4) world.set(m.l[i], m.l[i + 1], m.l[i + 2], m.l[i + 3]); break;
      case 'snap': {
        simTime = m.time;
        const seen = new Set();
        for (const a of m.mobs) {
          seen.add(a[0]); let mo = mobs.get(a[0]);
          if (!mo) { mo = { id: a[0], rx: a[2], ry: a[3], rz: a[4], ryaw: a[5], walk: 0 }; mobs.set(a[0], mo); }
          Object.assign(mo, { type: a[1], x: a[2], y: a[3], z: a[4], yaw: a[5], fl: a[6], ex: a.length > 7 ? a.slice(7) : null }); // ex: boss [hp%, floor] or pet [name, owner, level]
        }
        for (const k of [...mobs.keys()]) if (!seen.has(k)) mobs.delete(k);
        arrows = m.arrows; tnts = m.tnts;
        const ps = new Set();
        for (const p of m.players) {
          if (p[0] === myId) continue; ps.add(p[0]);
          let o = others.get(p[0]);
          if (!o) { o = { id: p[0], rx: p[2], ry: p[3], rz: p[4], walk: 0 }; others.set(p[0], o); }
          Object.assign(o, { name: p[1], x: p[2], y: p[3], z: p[4], yaw: p[5], pitch: p[6], dead: p[7] });
        }
        for (const k of [...others.keys()]) if (!ps.has(k)) others.delete(k);
        const vs = new Set();
        for (const a of m.vehicles || []) {
          vs.add(a[0]); let v = vehicles.get(a[0]);
          if (!v) { v = { id: a[0], rx: a[2], ry: a[3], rz: a[4], ryaw: a[5], spin: 0 }; vehicles.set(a[0], v); }
          Object.assign(v, { kind: a[1], x: a[2], y: a[3], z: a[4], yaw: a[5], pitch: a[6], roll: a[7], rider: a[8], owner: a[9] || '' });
        }
        for (const k of [...vehicles.keys()]) if (!vs.has(k)) vehicles.delete(k);
        if (ride && (!vehicles.has(ride.id) || vehicles.get(ride.id).rider !== myId)) { ride = null; engineSound(false); rideButtons(); }
        break;
      }
      case 'vride': if (pl && !pl.dead && state === 'play') startRide(m); break;
      case 'owned': {
        const mine = l => (l || []).filter(c => c.owner.toLowerCase() === myName.toLowerCase()).length;
        if (mine(m.list) > mine(owned)) earn('kit');
        owned = m.list || []; break;
      }
      case 'kill': { // a monster you (or your pet) defeated: XP and achievements
        const d = E.MOBS[m.type], base = d.base || m.type;
        if (d.boss) {
          addXp(100 + 50 * (m.floor || 1)); earn('boss');
          giveDrops([[m.floor >= 3 ? 193 : 192, 1]]);
          announce(d.name + ' IS DEFEATED!', (m.helped ? 'You helped beat it! ' : '') + 'The stairs to Floor ' + ((m.floor || 1) + 1) + ' are open in the middle of the room. Reward: a ' + (m.floor >= 3 ? 'Legendary' : 'Gold') + ' Loot Box.', '#FF5555');
          break;
        }
        addXp({ zombie: 5, skeleton: 6, spider: 5, creeper: 7, raptor: 6, trike: 10, trex: 30, brachio: 12 }[base] || 2);
        earn('first_blood');
        if (m.pet) earn('pet_win');
        const n = count('kill_' + base);
        if (base === 'zombie' && n >= 10) earn('zombie10'); if (base === 'skeleton' && n >= 10) earn('skeleton10');
        if (base === 'spider' && n >= 5) earn('spider5'); if (base === 'trex') earn('trex_down');
        break;
      }
      case 'tp': { // went down or up a stairwell
        leaveRide();
        Object.assign(pl, { x: m.x, y: m.y, z: m.z, vx: 0, vy: 0, vz: 0, fallY: null });
        state = 'loading'; $('loading').classList.remove('hidden'); $('loadText').textContent = m.floor ? 'FLOOR ' + m.floor : 'BACK TO THE SURFACE';
        if (m.floor) {
          earn('dungeon'); if (m.floor >= 3) earn('floor3'); if (m.floor >= 5) earn('floor5');
          const h = H(); if (m.floor > h.deepest) { h.deepest = m.floor; addXp(25 * m.floor); }
          announce('FLOOR ' + m.floor, 'Start in the safe room (blue floor): no monsters, and you heal. Find the boss room to open the stairs down. The green stairwell takes you back up.', '#AA55FF');
        }
        break;
      }
      case 'petinfo': {
        const had = myPet; myPet = m.pet;
        if (!had) earn('pet');
        if (m.up) announce(m.pet.name.toUpperCase() + ' LEVELED UP!', 'Your pet is now level ' + m.pet.lvl + ' and hits harder.', '#FFAA00');
        if (state === 'hero') renderHero();
        break;
      }
      case 'hurt': if (pl && !pl.dead && !creative()) { pl.vx += m.kx * 5; pl.vz += m.kz * 5; pl.vy = Math.max(pl.vy, 4); hurt(m.amt, 'You were slain by a monster', true);
        if (m.src === 'trex' || m.src === 'dlord') earn('dino_snack'); if (m.src === 'boom') earn('kaboom'); } break;
      case 'drop': giveDrops(m.items); break;
      case 'chest': if (m.k === openChest) { chestSlots = m.slots.slice(); openInv('', m.k); } break;
      case 'msg': toast(m.text); break;
      case 'time': simTime = m.time; break;
      case 'boom': sfx('boom', m.x, m.y, m.z); if (pl && Math.hypot(m.x - pl.x, m.z - pl.z) < 16) shake = 0.6; break;
      case 'sfx': sfx(m.s, m.x, m.y, m.z); break;
    }
  }
  function localLink() {
    return { send: m => sim.handle(1, m), tick: dt => sim.tick(dt), local: true };
  }
  function remoteLink(name, code) {
    const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + BASE + 'ws');
    const l = { ws, send: m => { if (ws.readyState === 1) ws.send(JSON.stringify(m)); }, tick: () => {}, local: false };
    ws.onopen = () => { $('status').textContent = 'Connected, joining...'; ws.send(JSON.stringify({ t: 'join', name, code })); };
    ws.onmessage = e => { try { onMsg(JSON.parse(e.data)); } catch (err) { console.error(err); } };
    ws.onerror = () => { $('status').textContent = 'Could not reach the game server.'; };
    ws.onclose = () => {
      if (!(session && session.remote && state !== 'menu')) return;
      saveGame(); quitToMenu(true);
      // stay signed in: reconnect by itself a few times (server restarts, Wi-Fi blips)
      if (cfg.signedIn && retries < 6) {
        retries++; $('menu').classList.add('hidden'); $('mpMenu').classList.remove('hidden');
        $('status').textContent = 'Lost the connection, trying again... (' + retries + ')';
        setTimeout(() => { if (state === 'menu' && !$('mpMenu').classList.contains('hidden')) link = remoteLink(cfg.name, cfg.code); }, 3000);
      } else $('status').textContent = 'Disconnected. Tap JOIN to try again.';
    };
    return l;
  }
  let retries = 0;

  // ---------------------------------------------------------------- sessions, saving
  const invSave = () => inv.map(s => s ? [s.id, s.n, s.d || 0] : 0);
  const invLoad = a => { inv = new Array(36).fill(null); (a || []).forEach((s, i) => { if (s && i < 36) inv[i] = { id: s[0], n: s[1] }; if (s && s[2]) inv[i].d = s[2]; }); };
  const playerData = () => ({ x: pl.x, y: ride ? groundBelow(pl.x, pl.y, pl.z) : pl.y, z: pl.z, yaw: pl.yaw, pitch: pl.pitch, hp: pl.hp, food: pl.food, inv: invSave(), sel, spawn: pl.spawn, fuel: pl.fuel, mode: pl.mode, armor: pl.armor, hero: H() });
  function listWorlds() { try { return JSON.parse(localStorage.getItem('blockcraft_worlds') || '[]'); } catch (e) { return []; } }
  function saveWorldList(l) { try { localStorage.setItem('blockcraft_worlds', JSON.stringify(l)); } catch (e) {} }
  function saveGame() {
    if (!session || !pl) return;
    if (session.remote) { link.send({ t: 'save', data: playerData() }); return; }
    const data = { seed: world.seed, time: sim.time, edits: world.encodeEdits(), chests: sim.chests, vehicles: sim.saveVehicles(), owned: sim.owned, dungeon: sim.dungeon, pets: sim.petData, spawn: session.spawn, player: playerData() };
    try { localStorage.setItem('blockcraft_w_' + session.id, JSON.stringify(data)); }
    catch (e) { toast('Could not save: this device is out of storage'); }
    const l = listWorlds(), w = l.find(q => q.id === session.id); if (w) { w.played = Date.now(); saveWorldList(l); }
  }
  function resetSessionState() {
    for (const m of chunkGL.values()) { gl.deleteBuffer(m.vbo); gl.deleteBuffer(m.wvbo); }
    chunkGL.clear(); mapTiles.clear(); mobs.clear(); others.clear(); vehicles.clear(); arrows = []; tnts = [];
    ride = null; engineSound(false); owned = []; myPet = null; inSafe = false; announceQ.length = 0; announceT = 0; $('announce').classList.add('hidden');
    target = null; breakT = 0; picked = -1; openChest = null;
  }
  function beginPlay() {
    $('menu').classList.add('hidden'); $('spMenu').classList.add('hidden'); $('mpMenu').classList.add('hidden');
    $('loading').classList.remove('hidden'); $('loadText').textContent = 'BUILDING WORLD...';
    state = 'loading'; lastStats = ''; renderHotbar();
  }
  function enterWorld() {
    $('loading').classList.add('hidden'); $('bar').classList.remove('hidden'); $('topbtns').classList.remove('hidden');
    for (const id of ['bJump', 'bDown', 'bFly']) $(id).classList.remove('hidden');
    rideButtons();
    state = 'play'; renderHotbar(); renderStats();
    toast(session.remote ? 'Welcome, ' + session.name + '!' : session.name);
  }
  function startLocal(w) {
    let data = null;
    try { data = JSON.parse(localStorage.getItem('blockcraft_w_' + w.id) || 'null'); } catch (e) {}
    resetSessionState();
    sim = new E.Sim({ seed: w.seed, difficulty: w.diff, time: data ? data.time : 0.02, chests: data ? data.chests : {}, vehicles: data ? data.vehicles : [], owned: data && data.owned || [], dungeon: data && data.dungeon, pets: data && data.pets || {},
      send: (m, to) => { if (to == null || to === 1) onMsg(m); } });
    world = sim.world;
    if (data && data.edits) world.decodeEdits(data.edits);
    const simChange = world.onChange; world.onChange = (...a) => { simChange(...a); clientOnChange(...a); };
    link = localLink(); myId = 1; myName = 'You'; owned = sim.owned;
    const spawn = (data && data.spawn) || sim.spawnPoint();
    session = { id: w.id, name: w.name, remote: false, spawn };
    sim.ensureEntrance(spawn); myPet = sim.petData.you || null;
    pl = newPlayer(spawn, w.mode);
    if (data && data.player) { const p = data.player; Object.assign(pl, { x: p.x, y: p.y, z: p.z, yaw: p.yaw, pitch: p.pitch, hp: p.hp, food: p.food, spawn: p.spawn, fuel: p.fuel || 0 }); if (p.armor) pl.armor = p.armor.slice(0, 4); if (p.hero) pl.hero = p.hero; invLoad(p.inv); sel = p.sel || 0; }
    else { inv = new Array(36).fill(null); sel = 0; if (w.mode === 'creative') [1, 4, 5, 6, 11, 22, 115, 21, 19].forEach((id, i) => { inv[i] = { id, n: E.stackSize(id) }; }); }
    sim.addPlayer(1, 'You', pl);
    beginPlay();
  }
  function startRemote(m) {
    resetSessionState();
    sim = null;
    world = new E.World(m.seed);
    world.decodeEdits(m.edits || {});
    world.onChange = clientOnChange;
    myId = m.id; myName = m.name; simTime = m.time; owned = m.owned || []; myPet = m.pet || null;
    session = { remote: true, name: m.name, spawn: m.spawn };
    pl = newPlayer(m.spawn, cfg.mpMode);
    if (m.you) { const p = m.you; Object.assign(pl, { x: p.x, y: p.y, z: p.z, yaw: p.yaw || 0, pitch: p.pitch || 0, hp: p.hp || 20, food: p.food || 20, spawn: p.spawn || null, fuel: p.fuel || 0 }); if (p.armor) pl.armor = p.armor.slice(0, 4); if (p.hero) pl.hero = p.hero; invLoad(p.inv); sel = p.sel || 0; }
    else { inv = new Array(36).fill(null); sel = 0; }
    pl.mode = cfg.mpMode;
    if (pl.mode === 'creative' && !inv.some(Boolean)) [1, 4, 5, 6, 11, 22, 115, 21, 19].forEach((id, i) => { inv[i] = { id, n: E.stackSize(id) }; });
    beginPlay();
  }
  function quitToMenu(skipSave) {
    if (!skipSave) { leaveRide(); saveGame(); }
    if (link && link.ws) { const ws = link.ws; session = null; ws.close(); }
    session = null; state = 'menu'; releasePointer();
    for (const id of ['bar', 'topbtns', 'bJump', 'bDown', 'bFly', 'bExit', 'pause', 'dead', 'inv', 'mapPanel', 'loading', 'hero']) $(id).classList.add('hidden');
    $('menu').classList.remove('hidden'); $('coords').textContent = '';
    resetSessionState(); pl = null; world = null; sim = null;
  }

  // ---------------------------------------------------------------- menus
  function pickRow(rowId, val) { for (const b of $(rowId).children) b.classList.toggle('on', b.dataset.v === val); }
  const rowVal = rowId => { const b = [...$(rowId).children].find(q => q.classList.contains('on')); return b ? b.dataset.v : null; };
  for (const r of ['nwMode', 'nwDiff', 'mpMode']) $(r).addEventListener('click', e => { if (e.target.dataset.v) pickRow(r, e.target.dataset.v); });
  $('optSound').checked = cfg.sound;
  $('optSound').onchange = () => { cfg.sound = $('optSound').checked; saveCfg(); };
  function showWorlds() {
    const l = listWorlds().sort((a, b) => (b.played || 0) - (a.played || 0));
    $('worldList').innerHTML = l.length ? l.map(w => '<div class="world"><div class="wn">' + w.name.replace(/</g, '&lt;') + '<small>' + (w.mode === 'creative' ? 'Creative' : 'Survival · ' + w.diff) +
      ' · seed ' + w.seed + '</small></div><button class="btn small" data-play="' + w.id + '">PLAY</button><button class="btn small red" data-del="' + w.id + '">DELETE</button></div>').join('')
      : '<div class="sub">No worlds yet. Make one below!</div>';
  }
  $('mSingle').onclick = () => { audio(); if (!glOK) return; $('menu').classList.add('hidden'); $('spMenu').classList.remove('hidden'); showWorlds(); };
  $('spBack').onclick = () => { $('spMenu').classList.add('hidden'); $('menu').classList.remove('hidden'); };
  $('worldList').addEventListener('click', e => {
    const p = e.target.dataset.play, d = e.target.dataset.del;
    if (p) { const w = listWorlds().find(q => q.id === p); if (w) startLocal(w); }
    if (d) {
      if (e.target.dataset.sure) { saveWorldList(listWorlds().filter(q => q.id !== d)); try { localStorage.removeItem('blockcraft_w_' + d); } catch (er) {} showWorlds(); }
      else { e.target.dataset.sure = '1'; e.target.textContent = 'SURE?'; }
    }
  });
  $('nwGo').onclick = () => {
    const name = $('nwName').value.trim() || 'My World';
    const st = $('nwSeed').value.trim();
    const seed = st ? (/^-?\d+$/.test(st) ? parseInt(st, 10) : hashStr(st) | 0) : (Math.random() * 2147483647) | 0;
    const w = { id: 'w' + Date.now().toString(36), name, seed, mode: rowVal('nwMode'), diff: rowVal('nwDiff'), played: Date.now() };
    const l = listWorlds(); l.push(w); saveWorldList(l);
    startLocal(w);
  };
  const KIDS = ['Lavon', 'Layla', 'Lilly', 'Luca', 'Dietrich'];
  $('mpNames').innerHTML = KIDS.map(n => '<button class="btn small" data-n="' + n + '">' + n.toUpperCase() + '</button>').join('');
  $('mpNames').addEventListener('click', e => { if (e.target.dataset.n) { $('mpName').value = e.target.dataset.n; for (const b of $('mpNames').children) b.classList.toggle('on', b === e.target); } });
  if (cfg.signedIn === undefined && cfg.name && cfg.code) cfg.signedIn = true; // joined before this option existed
  function updateMultiBtn() { $('mMulti').textContent = cfg.signedIn && cfg.name ? 'PLAY TOGETHER AS ' + cfg.name.toUpperCase() : 'PLAY TOGETHER'; }
  function showMpForm() { for (const el of $('mpMenu').querySelectorAll('.row, #mpGo')) el.classList.remove('hidden'); $('mpSwitch').classList.add('hidden'); }
  updateMultiBtn();
  $('mMulti').onclick = () => {
    audio(); if (!glOK) return;
    $('menu').classList.add('hidden'); $('mpMenu').classList.remove('hidden');
    $('mpName').value = cfg.name || ''; $('mpCode').value = cfg.code || ''; pickRow('mpMode', cfg.mpMode || 'survival'); $('status').textContent = '';
    for (const b of $('mpNames').children) b.classList.toggle('on', b.dataset.n === cfg.name);
    if (cfg.signedIn && cfg.name) { // already signed in on this tablet: go straight in
      for (const el of $('mpMenu').querySelectorAll('.row, #mpGo')) el.classList.add('hidden');
      $('mpSwitch').classList.remove('hidden'); $('mpSwitch').textContent = 'NOT ' + cfg.name.toUpperCase() + '?';
      $('status').textContent = 'Joining as ' + cfg.name + '...'; retries = 0;
      link = remoteLink(cfg.name, cfg.code);
    } else showMpForm();
  };
  $('mpSwitch').onclick = () => {
    if (link && link.ws) link.ws.close();
    cfg.signedIn = false; saveCfg(); updateMultiBtn(); showMpForm(); $('status').textContent = 'Pick a name and type the family code.';
  };
  for (const id of ['mpName', 'mpCode']) $(id).addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); $(id).blur(); $('mpGo').click(); } });
  $('mpBack').onclick = () => { $('mpMenu').classList.add('hidden'); $('menu').classList.remove('hidden'); };
  $('mpGo').onclick = () => {
    const name = $('mpName').value.trim();
    if (!name) { $('status').textContent = 'Pick your name first'; return; }
    cfg.name = name; cfg.code = $('mpCode').value.trim(); cfg.mpMode = rowVal('mpMode'); saveCfg();
    $('status').textContent = 'Connecting...';
    link = remoteLink(name, cfg.code);
  };
  function openPause() {
    if (state !== 'play') return;
    state = 'pause'; releasePointer(); $('pause').classList.remove('hidden');
    pickRow('pDist', String(cfg.dist)); $('pSound').checked = cfg.sound; $('pCoords').checked = cfg.coords;
    $('pMode').textContent = creative() ? 'CREATIVE' : 'SURVIVAL';
    saveGame();
  }
  function resume() { $('pause').classList.add('hidden'); state = 'play'; }
  $('pResume').onclick = resume;
  $('pDist').addEventListener('click', e => { if (e.target.dataset.v) { cfg.dist = +e.target.dataset.v; saveCfg(); pickRow('pDist', e.target.dataset.v); } });
  $('pSound').onchange = () => { cfg.sound = $('pSound').checked; saveCfg(); };
  $('pCoords').onchange = () => { cfg.coords = $('pCoords').checked; saveCfg(); if (!cfg.coords) $('coords').textContent = ''; };
  $('pMode').onclick = () => {
    pl.mode = creative() ? 'survival' : 'creative'; pl.fly = false;
    if (session.remote) { cfg.mpMode = pl.mode; saveCfg(); }
    else { const l = listWorlds(), w = l.find(q => q.id === session.id); if (w) { w.mode = pl.mode; saveWorldList(l); } }
    $('pMode').textContent = creative() ? 'CREATIVE' : 'SURVIVAL';
    rideButtons();
    $('palette').innerHTML = ''; lastStats = '';
  };
  $('pQuit').onclick = () => quitToMenu(false);
  document.addEventListener('visibilitychange', () => { if (document.hidden && session) { saveGame(); if (state === 'play') openPause(); } });
  addEventListener('beforeunload', () => { if (session) saveGame(); });

  // ---------------------------------------------------------------- rendering
  function resize() {
    const scale = isTouch ? Math.min(devicePixelRatio || 1, 1.25) : Math.min(devicePixelRatio || 1, 2);
    cv.width = Math.round(innerWidth * scale); cv.height = Math.round(innerHeight * scale);
    hud.width = innerWidth; hud.height = innerHeight;
  }
  addEventListener('resize', resize); resize();
  const lerpC = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
  let lastMVP = null;
  function render(dt) {
    const dl = E.dayLight(simTime);
    const eye = [pl.x, pl.y + 1.62, pl.z];
    if (ride) { // camera behind and above, pulled in if a wall is in the way
      const f0 = fwd(), cx = ride.x, cy = ride.y + 1.6, cz = ride.z;
      let d = ride.kind === 'plane' ? 8 : 7;
      const h = E.raycast(world, cx, cy, cz, -f0[0], -f0[1], -f0[2], d, false);
      if (h) d = Math.max(1.2, h.d - 0.4);
      eye[0] = cx - f0[0] * d; eye[1] = cy - f0[1] * d + 0.8; eye[2] = cz - f0[2] * d;
    }
    if (shake > 0) { eye[0] += (Math.random() - 0.5) * shake * 0.3; eye[1] += (Math.random() - 0.5) * shake * 0.3; shake -= dt; }
    const under = world.peek(Math.floor(eye[0]), Math.floor(eye[1]), Math.floor(eye[2])) === 10;
    let sky = lerpC([0.03, 0.04, 0.12], [0.56, 0.74, 0.95], clamp((dl - 0.12) / 0.88, 0, 1));
    const fogEnd = cfg.dist * CS, fogStart = fogEnd * 0.5;
    let fog = [fogStart, fogEnd];
    if (under) { sky = [0.08, 0.16, 0.45]; fog = [0, 12]; }
    else if (E.inDungeon(pl.x)) { sky = [0.02, 0.015, 0.04]; fog = [10, 34]; } // the dungeon: dark, short sight
    gl.viewport(0, 0, cv.width, cv.height);
    gl.clearColor(sky[0], sky[1], sky[2], 1); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    const proj = perspective(70 * Math.PI / 180, cv.width / cv.height, 0.05, fogEnd + 32);
    const view = viewMat(pl.yaw, pl.pitch, eye[0], eye[1], eye[2]);
    const mvp = mul(proj, view); lastMVP = mvp;
    // sun and moon
    gl.useProgram(flat); gl.disable(gl.DEPTH_TEST); gl.disable(gl.CULL_FACE);
    gl.enableVertexAttribArray(flat.a);
    const ang = simTime * Math.PI * 2;
    for (const [s, col] of [[1, [1, 0.97, 0.75, 1]], [-1, [0.85, 0.88, 0.95, 1]]]) {
      const dx = Math.cos(ang) * s, dy = Math.sin(ang) * s;
      if (dy < -0.15) continue;
      const cx = eye[0] + dx * 80, cy = eye[1] + dy * 80, cz = eye[2] + 10, r = s > 0 ? 7 : 5;
      const ux = -dy * r, uy = dx * r;
      const q = new Float32Array([cx - ux, cy - uy, cz - r, cx + ux, cy + uy, cz - r, cx + ux, cy + uy, cz + r, cx - ux, cy - uy, cz - r, cx + ux, cy + uy, cz + r, cx - ux, cy - uy, cz + r]);
      gl.bindBuffer(gl.ARRAY_BUFFER, dynVBO); gl.bufferData(gl.ARRAY_BUFFER, q, gl.DYNAMIC_DRAW);
      gl.vertexAttribPointer(flat.a, 3, gl.FLOAT, false, 0, 0);
      gl.uniformMatrix4fv(flat.uM, false, mvp); gl.uniform4fv(flat.uC, col);
      gl.drawArrays(gl.TRIANGLES, 0, 6);
    }
    gl.disableVertexAttribArray(flat.a);
    // world
    gl.useProgram(prog); gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
    gl.enableVertexAttribArray(prog.a.p); gl.enableVertexAttribArray(prog.a.t); gl.enableVertexAttribArray(prog.a.l);
    gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, atlasTex);
    gl.uniform1i(prog.u.uTex, 0); gl.uniformMatrix4fv(prog.u.uM, false, mvp); gl.uniform3fv(prog.u.uCam, eye);
    gl.uniform1f(prog.u.uDay, 0.28 + dl * 0.72); gl.uniform2fv(prog.u.uFog, fog); gl.uniform3fv(prog.u.uFogC, sky);
    gl.uniform1f(prog.u.uMin, pl.fx.night > 0 ? 0.75 : E.inDungeon(pl.x) ? (H().cls === 'explorer' ? 0.4 : 0.28) : H().cls === 'explorer' ? 0.2 : 0.04); // night vision potion: dark places look lit
    gl.uniform1f(prog.u.uAlpha, 1); gl.uniform4fv(prog.u.uTint, [0, 0, 0, 0]);
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, quadIBO);
    const f = fwd(), visible = [];
    for (const m of chunkGL.values()) {
      const rx = m.cx * CS + 8 - eye[0], rz = m.cz * CS + 8 - eye[2];
      if (rx * f[0] + rz * f[2] < -14 * Math.SQRT2 - 8 && Math.abs(f[1]) < 0.8) continue;
      if (Math.hypot(rx, rz) > fogEnd + 24) continue;
      visible.push(m);
      if (!m.n) continue;
      gl.bindBuffer(gl.ARRAY_BUFFER, m.vbo); bindAttribs();
      gl.drawElements(gl.TRIANGLES, m.n, IDX_TYPE(), 0);
    }
    // mobs, players, arrows, tnt
    const k = Math.min(1, dt * 12);
    for (const mo of mobs.values()) {
      const d0 = Math.hypot(mo.x - mo.rx, mo.z - mo.rz);
      mo.rx += (mo.x - mo.rx) * k; mo.ry += (mo.y - mo.ry) * k; mo.rz += (mo.z - mo.rz) * k;
      let dy = mo.yaw - mo.ryaw; while (dy > Math.PI) dy -= 2 * Math.PI; while (dy < -Math.PI) dy += 2 * Math.PI; mo.ryaw += dy * k;
      if (mo.fl & 8) mo.walk += dt * 9; else mo.walk *= 0.9;
      const type = E.MOB_TYPES[mo.type], md = E.MOBS[type], base = md.base || type;
      const tint = mo.fl & 1 ? [1, 0, 0, 0.45] : mo.fl & 2 ? [1, 1, 1, 0.65] : mo.fl & 4 ? [1, 0.5, 0, 0.3] : md.boss ? [0.4, 0, 0.6, 0.18] : null;
      drawModel(MODELS[base], mo.rx, mo.ry, mo.rz, mo.ryaw, mo.walk, 0, tint, base === 'zombie', (md.scale || 1) * (base === 'creeper' && mo.fl & 2 ? 1.08 : 1));
      if (type === 'zombie' && d0 >= 0) { groanT -= dt * 0.15; }
    }
    if (groanT <= 0) { groanT = 6 + Math.random() * 8; const z = [...mobs.values()].find(q => E.MOB_TYPES[q.type] === 'zombie'); if (z) sfx('groan', z.rx, z.ry, z.rz); }
    const riders = new Set();
    for (const v of vehicles.values()) {
      if (v.rider) riders.add(v.rider);
      if (ride && v.id === ride.id) continue;
      v.rx += (v.x - v.rx) * k; v.ry += (v.y - v.ry) * k; v.rz += (v.z - v.rz) * k; v.ryaw += angDiff(v.yaw, v.ryaw) * k;
      v.rp = (v.rp || 0) + (v.pitch - (v.rp || 0)) * k; v.rr = (v.rr || 0) + (v.roll - (v.rr || 0)) * k;
      v.spin += dt * (v.rider ? 30 : 0);
      drawVehicle(vehicleBoxes(E.VEH_TYPES[v.kind], paintFor(v.owner), !!v.rider), v.rx, v.ry, v.rz, v.ryaw + Math.PI, v.rp, v.rr, v.spin);
    }
    if (ride) drawVehicle(vehicleBoxes(ride.kind, paintFor(myName), true), ride.x, ride.y, ride.z, ride.yaw + Math.PI, ride.pitch, ride.roll, ride.spin);
    for (const o of others.values()) {
      const d0 = Math.hypot(o.x - o.rx, o.z - o.rz);
      o.rx += (o.x - o.rx) * k; o.ry += (o.y - o.ry) * k; o.rz += (o.z - o.rz) * k;
      o.walk += d0 > 0.02 ? dt * 9 : 0;
      if (!o.dead && !riders.has(o.id)) drawModel(MODELS.player, o.rx, o.ry, o.rz, o.yaw + Math.PI, o.walk, o.pitch, null, false);
    }
    for (const a of arrows) {
      const yaw = Math.atan2(a[4], a[6]);
      drawModel([{ b: [-0.04, -0.04, -0.3, 0.04, 0.04, 0.3], f: T.arrow_tex, o: T.arrow_tex }], a[1], a[2], a[3], yaw, 0, 0, null);
    }
    for (const tn of tnts) {
      const def = blk(32);
      drawModel([{ b: [-0.49, 0, -0.49, 0.49, 0.98, 0.49], f: def.tex[2], o: def.tex[2] }], tn[1], tn[2], tn[3], 0, 0, 0, tn[4] ? [1, 1, 1, 0.6] : null);
    }
    // water
    gl.uniform4fv(prog.u.uTint, [0, 0, 0, 0]);
    gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false); gl.disable(gl.CULL_FACE);
    gl.uniform1f(prog.u.uAlpha, 0.72);
    for (const m of visible) { if (!m.wn) continue; gl.bindBuffer(gl.ARRAY_BUFFER, m.wvbo); bindAttribs(); gl.drawElements(gl.TRIANGLES, m.wn, IDX_TYPE(), 0); }
    gl.depthMask(true); gl.disable(gl.BLEND);
    gl.disableVertexAttribArray(prog.a.p); gl.disableVertexAttribArray(prog.a.t); gl.disableVertexAttribArray(prog.a.l);
    // block outline
    if (target && target.block) {
      const b = target.block, d = blk(b.id), e = 0.003, x0 = b.x - e, y0 = b.y - e, z0 = b.z - e, x1 = b.x + 1 + e, y1 = b.y + (d.h < 1 ? d.h : 1) + e, z1 = b.z + 1 + e;
      const L = new Float32Array([x0, y0, z0, x1, y0, z0, x1, y0, z0, x1, y0, z1, x1, y0, z1, x0, y0, z1, x0, y0, z1, x0, y0, z0,
        x0, y1, z0, x1, y1, z0, x1, y1, z0, x1, y1, z1, x1, y1, z1, x0, y1, z1, x0, y1, z1, x0, y1, z0,
        x0, y0, z0, x0, y1, z0, x1, y0, z0, x1, y1, z0, x1, y0, z1, x1, y1, z1, x0, y0, z1, x0, y1, z1]);
      gl.useProgram(flat); gl.enableVertexAttribArray(flat.a);
      gl.bindBuffer(gl.ARRAY_BUFFER, dynVBO); gl.bufferData(gl.ARRAY_BUFFER, L, gl.DYNAMIC_DRAW);
      gl.vertexAttribPointer(flat.a, 3, gl.FLOAT, false, 0, 0);
      gl.uniformMatrix4fv(flat.uM, false, mvp); gl.uniform4fv(flat.uC, [0, 0, 0, 0.8]);
      gl.drawArrays(gl.LINES, 0, 24); gl.disableVertexAttribArray(flat.a);
    }
    drawHUD(under, mvp);
  }
  function project(mvp, x, y, z) {
    const cx = mvp[0] * x + mvp[4] * y + mvp[8] * z + mvp[12], cy = mvp[1] * x + mvp[5] * y + mvp[9] * z + mvp[13], cw = mvp[3] * x + mvp[7] * y + mvp[11] * z + mvp[15];
    if (cw <= 0.1) return null;
    return [(cx / cw * 0.5 + 0.5) * hud.width, (1 - (cy / cw * 0.5 + 0.5)) * hud.height];
  }
  function drawHUD(under, mvp) {
    const W = hud.width, H = hud.height;
    hx.clearRect(0, 0, W, H);
    if (under) { hx.fillStyle = 'rgba(20,50,160,.25)'; hx.fillRect(0, 0, W, H); }
    const cx = W / 2, cy = H / 2;
    hx.strokeStyle = 'rgba(255,255,255,.85)'; hx.lineWidth = 2;
    if (!ride) { hx.beginPath(); hx.moveTo(cx - 9, cy); hx.lineTo(cx + 9, cy); hx.moveTo(cx, cy - 9); hx.lineTo(cx, cy + 9); hx.stroke(); }
    // potion effects still running, top left
    let fy = ride ? 80 : 30;
    hx.font = 'bold 15px "Lucida Console",monospace'; hx.textAlign = 'left';
    for (const k in pl.fx) { hx.fillStyle = '#000'; hx.fillText(FX_NAMES[k] + ' ' + Math.ceil(pl.fx[k]), 13, fy + 1); hx.fillStyle = '#FF77FF'; hx.fillText(FX_NAMES[k] + ' ' + Math.ceil(pl.fx[k]), 12, fy); fy += 20; }
    if (ride) { // flying: speed and height instead of the crosshair
      const sp = ride.kind === 'plane' ? ride.speed : Math.hypot(ride.vx, ride.vz);
      hx.font = 'bold 18px "Lucida Console",monospace'; hx.textAlign = 'left'; hx.fillStyle = '#FFFF55';
      hx.fillText('SPEED ' + Math.round(sp * 3.6) + '   HEIGHT ' + Math.max(0, Math.floor(ride.y - E.SEA)), 12, 30);
      if (ride.kind === 'plane' && ride.speed < 12) { hx.fillStyle = '#fff'; hx.fillText(ride.onGround ? 'Go faster to take off' : 'Too slow!', 12, 54); }
    }
    else if (breakT > 0 && target && target.veh) { hx.strokeStyle = '#55FF55'; hx.lineWidth = 4; hx.beginPath(); hx.arc(cx, cy, 18, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, breakT)); hx.stroke(); }
    else if (breakT > 0 && target && target.block) {
      const tt = breakTime(target.block.id);
      if (tt > 0 && tt < Infinity) { hx.strokeStyle = '#FFFF55'; hx.lineWidth = 4; hx.beginPath(); hx.arc(cx, cy, 18, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, breakT / tt)); hx.stroke(); }
    }
    hx.font = '14px "Lucida Console",monospace'; hx.textAlign = 'center';
    for (const o of others.values()) {
      if (o.dead) continue;
      const p = project(mvp, o.rx, o.ry + 2.2, o.rz); if (!p) continue;
      const w = hx.measureText(o.name).width + 8;
      hx.fillStyle = 'rgba(0,0,0,.5)'; hx.fillRect(p[0] - w / 2, p[1] - 14, w, 18);
      hx.fillStyle = '#fff'; hx.fillText(o.name, p[0], p[1]);
    }
    // pet name tags, and the health bar of a floor boss you are near
    let boss = null;
    for (const mo of mobs.values()) {
      const md = E.MOBS[E.MOB_TYPES[mo.type]];
      if (md.boss && mo.ex && Math.hypot(mo.rx - pl.x, mo.rz - pl.z) < 40) boss = { md, hp: mo.ex[0] };
      if (md.pet && mo.ex && Math.hypot(mo.rx - pl.x, mo.rz - pl.z) < 24) {
        const p = project(mvp, mo.rx, mo.ry + md.h + 0.5, mo.rz); if (!p) continue;
        const label = mo.ex[0] + ' (lv ' + mo.ex[2] + ')', w = hx.measureText(label).width + 8;
        hx.fillStyle = 'rgba(0,0,0,.5)'; hx.fillRect(p[0] - w / 2, p[1] - 14, w, 18);
        hx.fillStyle = PAINTS[paintFor(mo.ex[1])]; hx.fillText(label, p[0], p[1]);
      }
    }
    if (boss) {
      const bw = Math.min(W * 0.6, 420), bx = (W - bw) / 2, by = H - Math.min(150, H * 0.24); // above the hotbar, clear of the announcer
      hx.fillStyle = 'rgba(0,0,0,.6)'; hx.fillRect(bx - 4, by - 22, bw + 8, 38);
      hx.fillStyle = '#FF5555'; hx.font = 'bold 15px "Lucida Console",monospace'; hx.fillText(boss.md.name, W / 2, by - 6);
      hx.fillStyle = '#400'; hx.fillRect(bx, by, bw, 10); hx.fillStyle = '#e02020'; hx.fillRect(bx, by, bw * boss.hp / 100, 10);
      hx.font = '14px "Lucida Console",monospace';
    }
    if (E.inDungeon(pl.x)) {
      hx.font = 'bold 16px "Lucida Console",monospace'; hx.textAlign = 'right';
      hx.fillStyle = inSafe ? '#55FFFF' : '#AA77FF';
      hx.fillText('FLOOR ' + E.floorAt(pl.x) + (inSafe ? '  SAFE ROOM' : ''), W - 12, 72);
      hx.textAlign = 'center'; hx.font = '14px "Lucida Console",monospace';
    }
    for (const c of owned) { // "Layla's Royal Castle" over kit castles nearby
      const mx = c.x0 + 10.5, mz = c.z0 + 10.5; if (Math.hypot(mx - pl.x, mz - pl.z) > 48) continue;
      const p = project(mvp, mx, c.base + 13, mz); if (!p) continue;
      const label = (session.remote ? c.owner + "'s " : 'Your ') + E.CASTLES[c.type].name, w = hx.measureText(label).width + 8;
      hx.fillStyle = 'rgba(0,0,0,.5)'; hx.fillRect(p[0] - w / 2, p[1] - 14, w, 18);
      hx.fillStyle = PAINTS[paintFor(c.owner)]; hx.fillText(label, p[0], p[1]);
    }
    if (session && session.remote) for (const v of vehicles.values()) { // "Layla's Airplane" over parked vehicles nearby
      if (v.rider || !v.owner || Math.hypot(v.rx - pl.x, v.rz - pl.z) > 24) continue;
      const p = project(mvp, v.rx, v.ry + 2.6, v.rz); if (!p) continue;
      const label = v.owner + "'s " + (E.VEH_TYPES[v.kind] === 'plane' ? 'Airplane' : 'Helicopter'), w = hx.measureText(label).width + 8;
      hx.fillStyle = 'rgba(0,0,0,.5)'; hx.fillRect(p[0] - w / 2, p[1] - 14, w, 18);
      hx.fillStyle = PAINTS[paintFor(v.owner)]; hx.fillText(label, p[0], p[1]);
    }
    hx.textAlign = 'left';
    $('hurt').style.opacity = hurtFlash > 0 ? Math.min(1, hurtFlash * 2) : 0;
  }

  // walking into a castle says which kind it is (once per castle per visit)
  let castleT = 0, inCastle = '';
  function castleCheck(dt) {
    castleT -= dt; if (castleT > 0) return; castleT = 0.5;
    const x = Math.floor(pl.x), z = Math.floor(pl.z), mine = castleOwnerAt(x, z);
    let k = '', msg = '';
    if (mine) { k = 'k' + mine.x0 + ',' + mine.z0; msg = (mine.owner.toLowerCase() === myName.toLowerCase() ? 'Your ' : mine.owner + "'s ") + E.CASTLES[mine.type].name; }
    else {
      const c = E.castleAt(Math.floor(x / E.REG), Math.floor(z / E.REG), world.seed);
      if (c && x >= c.x0 && x < c.x0 + E.CW && z >= c.z0 && z < c.z0 + E.CW) {
        k = c.x0 + ',' + c.z0; msg = 'You found a ' + E.CASTLES[c.type].name + '!\nLook for the treasure chest in the keep';
        const h = H(); h.castles[c.type] = 1; earn('castle'); if (c.type === 'royal') earn('royal'); if (Object.keys(h.castles).length >= 5) earn('all_castles');
      }
    }
    inSafe = E.inSafeRoom(pl.x, pl.z, world.seed);
    if (inSafe) earn('safe');
    if (k && k !== inCastle) { toast(msg); sfx('craft'); }
    inCastle = k;
  }

  // ---------------------------------------------------------------- main loop
  let last = performance.now();
  function frame(now) {
    requestAnimationFrame(frame);
    const dt = Math.min(0.05, (now - last) / 1000); last = now;
    if (!pl || !world) return;
    if (state === 'loading') {
      updateChunks(40);
      if (nearReady()) {
        const top = world.topY(Math.floor(pl.x), Math.floor(pl.z));
        if (pl.y < top + 1 && isSolid[world.get(Math.floor(pl.x), Math.floor(pl.y + 0.5), Math.floor(pl.z))]) pl.y = top + 1;
        enterWorld();
      }
      return;
    }
    const active = state === 'play' || state === 'inv' || state === 'map' || state === 'hero' || state === 'dead' || (state === 'pause' && session && session.remote);
    if (active) {
      if (state === 'play') { updatePlayer(dt); interact(dt); castleCheck(dt); }
      tickAnnounce(dt);
      if (link.local) { simTime = sim.time; sim.players.get(1) && Object.assign(sim.players.get(1), { x: pl.x, y: pl.y, z: pl.z, yaw: pl.yaw, pitch: pl.pitch, dead: pl.dead }); }
      link.tick(dt);
      moveSend -= dt;
      vSend -= dt;
      if (ride && vSend <= 0) { vSend = 0.1; const r2 = v => +v.toFixed(2); link.send({ t: 'vmove', id: ride.id, x: r2(ride.x), y: r2(ride.y), z: r2(ride.z), yaw: r2(ride.yaw), pitch: r2(ride.pitch), roll: r2(ride.roll) }); }
      if (moveSend <= 0 && !link.local) { moveSend = 0.1; link.send({ t: 'move', x: +pl.x.toFixed(2), y: +pl.y.toFixed(2), z: +pl.z.toFixed(2), yaw: +pl.yaw.toFixed(2), pitch: +pl.pitch.toFixed(2), dead: pl.dead }); }
      saveTimer -= dt; if (saveTimer <= 0) { saveTimer = session.remote ? 10 : 30; saveGame(); }
    }
    hurtFlash = Math.max(0, hurtFlash - dt);
    if (toastT > 0) { toastT -= dt; if (toastT <= 0) $('toast').textContent = ''; }
    updateChunks(isTouch ? 6 : 10);
    render(dt);
    renderStats();
    fpsN++; if (now - fpsT > 1000) { fps = Math.round(fpsN * 1000 / (now - fpsT)); fpsN = 0; fpsT = now; }
    if (cfg.coords && state === 'play') $('coords').textContent = 'X ' + Math.floor(pl.x) + '  Y ' + Math.floor(pl.y) + '  Z ' + Math.floor(pl.z) + '  ' + (dl2txt(E.dayLight(simTime))) + '  ' + fps + ' fps';
  }
  let fps = 0, fpsN = 0, fpsT = 0;
  const dl2txt = d => d > 0.6 ? 'day' : d > 0.3 ? 'dusk' : 'night';
  requestAnimationFrame(frame);
  window.BCdebug = () => ({ pl, world, sim, mobs, chunkGL, state, target, inv, addItem, openInv, closeInv, doUse,
    dig: () => target && target.block && breakBlock(target.block), select: i => { sel = i; renderHotbar(); }, others, vehicles, ride: () => ride, leaveRide, owned: () => owned, giveDrops });
})();
EOF

index.html: adds the HERO button and screen, the announcer banner and a line about the dungeon on the start screen.

cat > /root/blockcraft/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<meta name="theme-color" content="#000000">
<title>BlockCraft</title>
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="icon-512.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<meta property="og:title" content="BlockCraft">
<meta property="og:image" content="https://games.503mail.com/blockcraft/icon-512.png">
<style>
  html,body{margin:0;height:100%;background:#000;overflow:hidden;font-family:"Lucida Console","Courier New",monospace;color:#fff;
    -webkit-user-select:none;user-select:none;-webkit-touch-callout:none}
  canvas{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none}
  #hud{pointer-events:none;z-index:2}
  button{font:inherit;cursor:pointer}
  .hidden{display:none!important}
  /* overlays */
  .panel{position:fixed;inset:0;z-index:10;background:rgba(0,0,0,.82);display:flex;flex-direction:column;align-items:center;
    justify-content:center;gap:2vmin;padding:16px;box-sizing:border-box;font-size:clamp(13px,2.6vmin,20px);text-align:center;overflow:auto}
  .panel h1{margin:0;color:#FFFF55;letter-spacing:.25em;font-size:2em;text-shadow:3px 3px #555}
  .panel h2{margin:0;color:#55FFFF;letter-spacing:.2em;font-size:1.2em}
  .panel .sub{color:#AAAAAA}
  .btn{background:#0000AA;color:#fff;border:3px solid #55FFFF;padding:.5em 1.2em;min-width:11em;letter-spacing:.1em}
  .btn:active,.btn.on{background:#5555FF;border-color:#FFFF55;color:#FFFF55}
  .btn.red{border-color:#FF5555}
  .btn.small{min-width:0;padding:.3em .8em}
  .row{display:flex;gap:.8em;flex-wrap:wrap;justify-content:center;align-items:center}
  input[type=text],input[type=password]{font:inherit;background:#000;color:#FFFF55;border:3px solid #55FFFF;padding:.35em .6em;width:12em}
  .worlds{display:flex;flex-direction:column;gap:.6em;width:min(560px,100%)}
  .world{display:flex;gap:.6em;align-items:center;background:#111a;border:2px solid #555;padding:.5em}
  .world .wn{flex:1;text-align:left}
  .world .wn small{color:#AAAAAA;display:block}
  .help{color:#AAAAAA;font-size:.85em;line-height:1.6;max-width:44em}
  .help kbd{color:#FFFF55}
  a{color:#55FFFF}
  #status{color:#FF5555;min-height:1.2em}
  /* HUD */
  #bar{position:fixed;left:50%;bottom:1vmin;transform:translateX(-50%);z-index:4;display:flex;flex-direction:column;align-items:center;gap:.4vmin;pointer-events:none}
  #stats{display:flex;gap:4vmin;font-size:clamp(12px,3vmin,22px);text-shadow:1px 1px #000;pointer-events:none}
  #hearts{color:#FF5555;letter-spacing:-.1em}#food{color:#d9a066;letter-spacing:-.1em}
  #airbar{color:#55FFFF}
  #armorbar{color:#5decf5;letter-spacing:-.1em}
  #hotbar{display:flex;gap:.5vmin;background:rgba(0,0,0,.45);padding:.6vmin;border:2px solid #555;pointer-events:auto}
  .slot{position:relative;width:min(8.5vmin,58px);height:min(8.5vmin,58px);border:2px solid #777;background:rgba(40,40,40,.65);
    box-sizing:border-box;display:flex;align-items:center;justify-content:center}
  .slot img{width:78%;height:78%;image-rendering:pixelated;pointer-events:none}
  .slot .n{position:absolute;right:2px;bottom:0;font-size:clamp(10px,2.2vmin,15px);text-shadow:1px 1px #000;pointer-events:none}
  .slot .dur{position:absolute;left:10%;right:10%;bottom:6%;height:3px;background:#0a0;pointer-events:none}
  .slot.sel{border-color:#FFFF55;background:rgba(90,90,40,.7)}
  .slot.pick{border-color:#55FF55;background:rgba(40,110,40,.8)}
  #toast{position:fixed;left:50%;top:9vmin;transform:translateX(-50%);z-index:5;color:#fff;text-shadow:2px 2px #000;font-size:clamp(13px,2.8vmin,20px);
    text-align:center;pointer-events:none;white-space:pre-line}
  #coords{position:fixed;left:1vmin;top:1vmin;z-index:4;font-size:12px;text-shadow:1px 1px #000;pointer-events:none}
  #hurt{position:fixed;inset:0;z-index:3;background:radial-gradient(transparent 40%,rgba(255,0,0,.55));opacity:0;pointer-events:none;transition:opacity .25s}
  /* touch controls */
  #touch{position:fixed;inset:0;z-index:1;touch-action:none;display:none}
  body.touch #touch{display:block}
  .tb{display:none;position:fixed;z-index:4;touch-action:none;background:rgba(0,0,120,.45);color:#FFFF55;border:3px solid rgba(85,255,255,.8);
    border-radius:1.5vmin;font:bold clamp(12px,3vmin,22px) "Lucida Console","Courier New",monospace}
  body.touch .tb{display:block}
  .tb.held{background:rgba(85,85,255,.75)}
  #bJump{right:3vmin;bottom:15vmin;width:15vmin;height:15vmin;border-radius:50%}
  #bDown{right:20vmin;bottom:5vmin;width:12vmin;height:12vmin;border-radius:50%}
  #bFly{right:20vmin;bottom:19vmin;width:12vmin;height:9vmin}
  #bExit{position:fixed;z-index:4;left:50%;top:1.5vmin;transform:translateX(-50%);background:rgba(160,0,0,.6);color:#FFFF55;
    border:3px solid #FF5555;border-radius:1.2vmin;font:bold clamp(14px,3.2vmin,24px) "Lucida Console","Courier New",monospace;padding:1vmin 3vmin;touch-action:manipulation}
  #topbtns{position:fixed;right:1.5vmin;top:1.5vmin;z-index:4;display:flex;gap:1.2vmin}
  #topbtns button{background:rgba(0,0,120,.55);color:#FFFF55;border:3px solid rgba(85,255,255,.8);border-radius:1.2vmin;
    font:bold clamp(12px,2.8vmin,20px) "Lucida Console","Courier New",monospace;padding:1vmin 1.6vmin;touch-action:manipulation}
  #stickRing{position:fixed;z-index:3;width:26vmin;height:26vmin;margin:-13vmin 0 0 -13vmin;border-radius:50%;border:3px solid rgba(85,255,255,.6);
    background:rgba(0,0,80,.25);pointer-events:none;display:none}
  #stickKnob{position:absolute;left:50%;top:50%;width:10vmin;height:10vmin;margin:-5vmin 0 0 -5vmin;border-radius:50%;background:rgba(255,255,85,.75)}
  /* inventory */
  #inv{justify-content:flex-start}
  #invWrap{display:flex;gap:3vmin;flex-wrap:wrap;justify-content:center;align-items:flex-start;max-width:100%}
  .grid{display:grid;grid-template-columns:repeat(9,auto);gap:.5vmin;justify-content:center}
  .box{background:rgba(30,30,60,.9);border:3px solid #555;padding:1.2vmin}
  .box h3{margin:0 0 1vmin;color:#55FFFF;font-size:1em;letter-spacing:.15em}
  #recipes{display:flex;flex-direction:column;gap:.6vmin;max-height:62vh;overflow:auto;min-width:min(330px,90vw)}
  .rec{display:flex;align-items:center;gap:.6em;background:#0000AA;border:2px solid #55FFFF;padding:.3em .5em;text-align:left;color:#fff}
  .rec img{width:2em;height:2em;image-rendering:pixelated}
  .rec .ing{color:#AAAAAA;font-size:.8em}
  .rec.no{background:#222;border-color:#444;color:#888}
  .rec.no img{opacity:.45}
  #palette{display:grid;grid-template-columns:repeat(9,auto);gap:.5vmin;max-height:58vh;overflow:auto}
  #itemName{min-height:1.3em;color:#FFFF55}
  /* announcer banner, HERO screen */
  #announce{position:fixed;left:50%;top:13vmin;transform:translateX(-50%);z-index:6;width:min(560px,90vw);background:rgba(10,0,30,.88);
    border:3px solid #FFFF55;padding:1.2vmin 2vmin;text-align:center;pointer-events:none;font-size:clamp(12px,2.6vmin,18px);white-space:pre-line}
  #announce b{display:block;font-size:1.25em;letter-spacing:.08em;margin-bottom:.4em}
  #hero{justify-content:flex-start}
  .xpbar{width:min(420px,80vw);height:14px;border:2px solid #55FFFF;background:#111}
  .xpbar div{height:100%;background:#55FF55;width:0}
  #hAch{display:grid;grid-template-columns:repeat(auto-fill,minmax(13em,1fr));gap:.6vmin;max-width:min(900px,92vw);text-align:left;font-size:.85em}
  .ach{background:#111a;border:2px solid #333;padding:.4em;color:#777}
  .ach.got{border-color:#555;color:#ddd}
  /* map */
  #mapCv{position:static;width:min(92vw,92vh*1.5);height:min(72vh,92vw/1.5);border:3px solid #55FFFF;background:#000;image-rendering:pixelated}
</style>
</head>
<body>
<canvas id="gl"></canvas>
<canvas id="hud"></canvas>
<div id="touch"></div>
<div id="stickRing"><div id="stickKnob"></div></div>
<div id="hurt"></div>
<div id="coords"></div>
<div id="toast"></div>
<div id="bar" class="hidden">
  <div id="stats"><span id="armorbar"></span><span id="hearts"></span><span id="airbar"></span><span id="food"></span></div>
  <div id="hotbar"></div>
</div>
<button id="bJump" class="tb hidden" aria-label="Jump">JUMP</button>
<button id="bDown" class="tb hidden" aria-label="Fly down">&#9660;</button>
<button id="bFly" class="tb hidden" aria-label="Fly">FLY</button>
<button id="bExit" class="hidden" aria-label="Get out">OUT</button>
<div id="topbtns" class="hidden">
  <button id="bHero" aria-label="Hero">HERO</button>
  <button id="bInv" aria-label="Inventory">BAG</button>
  <button id="bMap" aria-label="Map">MAP</button>
  <button id="bPause" aria-label="Pause">II</button>
</div>

<div id="menu" class="panel">
  <h1>BLOCKCRAFT</h1>
  <div class="sub">BUILD · MINE · SURVIVE</div>
  <button class="btn" id="mSingle">SINGLE PLAYER</button>
  <button class="btn" id="mMulti">PLAY TOGETHER</button>
  <div class="row"><label><input type="checkbox" id="optSound" checked> Sound</label></div>
  <div id="nogl" class="hidden" style="color:#FF5555">This browser can't draw 3D (WebGL is off), so the game can't run here.</div>
  <div class="help">
    <b>Tablet:</b> left thumb moves, drag on the right to look. <b>Tap</b> to place or use, <b>hold</b> to dig or hit.<br>
    <b>Keyboard:</b> <kbd>WASD</kbd> move · mouse look · <kbd>left click</kbd> dig/hit · <kbd>right click</kbd> place/use ·
    <kbd>space</kbd> jump (double tap = fly in Creative) · <kbd>E</kbd> bag · <kbd>M</kbd> map · <kbd>1</kbd>–<kbd>9</kbd> pick<br>
    <b>Airplanes &amp; helicopters:</b> earn one in Survival (iron + diamonds at a crafting table), set it down, tap it to get in.
    It is yours only. <kbd>F</kbd> or <b>OUT</b> gets out.<br>
    <b>Treasure:</b> explore Wooden Forts, Stone and Royal Castles, Desert Palaces and Ice Castles for treasure chests.
    Tap potions to drink them, tap armor to wear it, and earn castle kits to build a castle of your own.<br>
    <b>Dungeon:</b> the glowing stairwell next to the start leads down to dungeon floors with bosses. <b>HERO</b> (<kbd>H</kbd>) shows your level,
    class, points, pet and achievements. Tap loot boxes to open them.
  </div>
  <a href="/">Other games</a>
</div>

<div id="spMenu" class="panel hidden">
  <h2>SINGLE PLAYER</h2>
  <div class="worlds" id="worldList"></div>
  <div class="box" style="display:flex;flex-direction:column;gap:1.2vmin;align-items:center">
    <h3>NEW WORLD</h3>
    <div class="row"><input type="text" id="nwName" maxlength="20" placeholder="World name"><input type="text" id="nwSeed" maxlength="12" placeholder="Seed (optional)"></div>
    <div class="row" id="nwMode"><button class="btn small on" data-v="survival">SURVIVAL</button><button class="btn small" data-v="creative">CREATIVE</button></div>
    <div class="row" id="nwDiff"><button class="btn small" data-v="peaceful">PEACEFUL</button><button class="btn small on" data-v="easy">EASY</button><button class="btn small" data-v="normal">NORMAL</button></div>
    <button class="btn" id="nwGo">CREATE</button>
  </div>
  <button class="btn small" id="spBack">BACK</button>
</div>

<div id="mpMenu" class="panel hidden">
  <h2>PLAY TOGETHER</h2>
  <div class="sub">Everyone joins the same family world.</div>
  <div class="row" id="mpNames"></div>
  <div class="row"><input type="text" id="mpName" maxlength="16" placeholder="Your name"></div>
  <div class="row"><input type="password" id="mpCode" maxlength="32" placeholder="Family code"></div>
  <div class="row" id="mpMode"><button class="btn small on" data-v="survival">SURVIVAL</button><button class="btn small" data-v="creative">CREATIVE</button></div>
  <button class="btn" id="mpGo">JOIN</button>
  <div id="status"></div>
  <button class="btn small hidden" id="mpSwitch">SWITCH PLAYER</button>
  <button class="btn small" id="mpBack">BACK</button>
</div>

<div id="loading" class="panel hidden"><h2 id="loadText">BUILDING WORLD...</h2></div>

<div id="pause" class="panel hidden">
  <h2>PAUSED</h2>
  <button class="btn" id="pResume">BACK TO GAME</button>
  <div class="row">View: <span class="row" id="pDist"><button class="btn small" data-v="2">NEAR</button><button class="btn small" data-v="3">MEDIUM</button><button class="btn small" data-v="5">FAR</button></span></div>
  <div class="row"><label><input type="checkbox" id="pSound"> Sound</label><label><input type="checkbox" id="pCoords"> Show position</label></div>
  <div class="row" id="pModeRow">Mode: <button class="btn small" id="pMode"></button></div>
  <button class="btn red" id="pQuit">SAVE &amp; QUIT</button>
</div>

<div id="dead" class="panel hidden">
  <h1 style="color:#FF5555">YOU DIED!</h1>
  <div class="sub" id="deadWhy"></div>
  <button class="btn" id="dRespawn">RESPAWN</button>
</div>

<div id="inv" class="panel hidden">
  <div id="itemName"></div>
  <div id="invWrap">
    <div class="box" id="chestBox"><h3>CHEST</h3><div class="grid" id="chestGrid"></div></div>
    <div class="box" id="creativeBox"><h3>ALL BLOCKS &amp; ITEMS</h3><div id="palette"></div></div>
    <div class="box" id="armorBox"><h3>WEARING</h3><div class="grid" id="armorGrid" style="grid-template-columns:repeat(4,auto)"></div><div class="sub" id="armorInfo" style="font-size:.8em;margin-top:.6vmin"></div></div>
    <div class="box"><h3>BAG</h3><div class="grid" id="invGrid"></div><div style="height:1vmin"></div><div class="grid" id="invHot"></div></div>
    <div class="box" id="craftBox"><h3 id="craftTitle">CRAFTING</h3><div id="recipes"></div></div>
  </div>
  <button class="btn small" id="invClose">CLOSE</button>
</div>

<div id="announce" class="hidden"><b></b><span></span></div>

<div id="hero" class="panel hidden">
  <h2 id="hLevel">HERO</h2>
  <div class="xpbar"><div id="hXp"></div></div><div class="sub" id="hXpText"></div>
  <div class="box" style="display:flex;flex-direction:column;gap:1vmin;align-items:center">
    <h3>CLASS</h3><div class="row" id="hClass"></div><div class="sub" id="hClassAbout"></div>
    <h3>POINTS</h3><div class="sub" id="hFree"></div><div id="hStats" style="display:flex;flex-direction:column;gap:.6vmin"></div>
    <h3>PET</h3><div id="hPet"></div>
    <div class="row hidden" id="hPetRow"><input type="text" id="hPetName" maxlength="16" placeholder="New pet name"><button class="btn small" id="hPetSave">NAME</button></div>
  </div>
  <div class="box"><h3 id="hAchTitle">ACHIEVEMENTS</h3><div id="hAch"></div></div>
  <button class="btn small" id="hClose">CLOSE</button>
</div>

<div id="mapPanel" class="panel hidden">
  <canvas id="mapCv" width="900" height="600"></canvas>
  <div class="row"><button class="btn small" id="mapOut">&minus;</button><button class="btn small" id="mapIn">+</button><button class="btn small" id="mapClose">CLOSE</button></div>
</div>

<script src="engine.js"></script>
<script src="game.js"></script>
</body>
</html>
EOF

server.js: builds the dungeon shrine once, lets the new messages through, sends each player their pet and the shrine's position, and saves the dungeon progress and the pets.

cat > /root/blockcraft/server.js <<'EOF'
'use strict';
// BlockCraft multiplayer server (CT 127). Serves the game page at /blockcraft/ and runs the shared
// world over a WebSocket at /blockcraft/ws. The world simulation is the same engine.js the page uses.
const http = require('http'), fs = require('fs'), path = require('path');
const WebSocket = require('ws');

const ROOT = __dirname;
const PUB = fs.existsSync(path.join(ROOT, 'public')) ? path.join(ROOT, 'public') : path.join(ROOT, '..', 'public');
const BC = require(path.join(PUB, 'engine.js'));
const DATA = path.join(ROOT, 'data'), WORLD_FILE = path.join(DATA, 'world.json');
const cfg = Object.assign({ port: 8080, code: '', difficulty: 'normal', names: [] },
  fs.existsSync(path.join(ROOT, 'config.json')) ? JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'), 'utf8')) : {});
fs.mkdirSync(DATA, { recursive: true });

// ---------------------------------------------------------------- world state
let saved = null;
try { saved = JSON.parse(fs.readFileSync(WORLD_FILE, 'utf8')); } catch (e) { saved = null; }
const seed = saved ? saved.seed : (Math.random() * 2147483647) | 0;
const conns = new Map(); // player id -> { ws, name }
const sim = new BC.Sim({ seed, difficulty: cfg.difficulty, time: saved ? saved.time : 0.02, chests: saved ? saved.chests : {},
  vehicles: saved ? saved.vehicles : [], owned: saved ? saved.owned : [], dungeon: saved ? saved.dungeon : null, pets: saved ? saved.pets : {}, send });
sim.unloadFar = true;
if (saved && saved.edits) sim.world.decodeEdits(saved.edits);
const spawn = (saved && saved.spawn) || sim.spawnPoint();
sim.ensureEntrance(spawn); // the dungeon shrine next to spawn (built once, then it's part of the saved changes)
const playersData = (saved && saved.players) || {};
let nextId = 1, dirty = true;
const origSet = sim.world.set.bind(sim.world);
sim.world.set = (...a) => { const r = origSet(...a); if (r) dirty = true; return r; };

function send(msg, to) {
  const s = JSON.stringify(msg);
  if (to == null) { for (const c of conns.values()) if (c.ws.readyState === 1) c.ws.send(s); }
  else { const c = conns.get(to); if (c && c.ws.readyState === 1) c.ws.send(s); }
}
function saveWorld() {
  const data = { seed, time: sim.time, spawn, edits: sim.world.encodeEdits(), chests: sim.chests, vehicles: sim.saveVehicles(), owned: sim.owned, dungeon: sim.dungeon, pets: sim.petData, players: playersData };
  const tmp = WORLD_FILE + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(data));
  fs.renameSync(tmp, WORLD_FILE);
  dirty = false;
}

// ---------------------------------------------------------------- web server
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.png': 'image/png', '.css': 'text/css' };
const server = http.createServer((req, res) => {
  let p = decodeURIComponent((req.url || '/').split('?')[0]);
  if (p === '/' || p === '/blockcraft') { res.writeHead(301, { Location: '/blockcraft/' }); res.end(); return; }
  if (!p.startsWith('/blockcraft/')) { res.writeHead(404); res.end('not found'); return; }
  p = p.slice('/blockcraft/'.length) || 'index.html';
  const file = path.normalize(path.join(PUB, p));
  if (!file.startsWith(PUB + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); res.end('not found'); return; }
  res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
  fs.createReadStream(file).pipe(res);
});

// ---------------------------------------------------------------- players
const wss = new WebSocket.Server({ server, path: '/blockcraft/ws', maxPayload: 256 * 1024 });
const ALLOWED = new Set(['move', 'block', 'hit', 'shoot', 'ignite', 'chest', 'chestSet', 'sleep', 'chat',
  'vplace', 'vride', 'vmove', 'vleave', 'vpickup', 'kit', 'stairs', 'pet', 'petname']);
wss.on('connection', (ws, req) => {
  let id = null, name = null;
  const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
  ws.on('message', raw => {
    let m; try { m = JSON.parse(raw); } catch (e) { return; }
    if (!m || typeof m.t !== 'string') return;
    if (id === null) {
      if (m.t !== 'join') return;
      const nm = String(m.name || '').trim();
      if (cfg.code && String(m.code || '') !== cfg.code) { ws.send(JSON.stringify({ t: 'error', text: 'Wrong family code.' })); return; }
      if (!/^[A-Za-z0-9 _-]{1,16}$/.test(nm)) { ws.send(JSON.stringify({ t: 'error', text: 'Names can use letters and numbers (up to 16).' })); return; }
      if (cfg.names.length && !cfg.names.map(n => n.toLowerCase()).includes(nm.toLowerCase())) { ws.send(JSON.stringify({ t: 'error', text: 'That name is not on the family list.' })); return; }
      for (const [oid, c] of conns) if (c.name.toLowerCase() === nm.toLowerCase()) { c.ws.close(); conns.delete(oid); sim.removePlayer(oid); }
      id = nextId++; name = nm;
      const you = playersData[nm.toLowerCase()] || null;
      conns.set(id, { ws, name });
      sim.addPlayer(id, name, you ? { x: you.x, y: you.y, z: you.z } : spawn);
      ws.send(JSON.stringify({ t: 'welcome', id, name, seed, time: sim.time, edits: sim.world.encodeEdits(), spawn, you, owned: sim.owned, pet: sim.petData[nm.toLowerCase()] || null, dungeon: sim.dungeon.entrance }));
      send({ t: 'msg', text: name + ' joined the game' }, null);
      console.log(new Date().toISOString(), 'join', name, ip);
      return;
    }
    if (m.t === 'save') {
      if (m.data && JSON.stringify(m.data).length < 30000) { playersData[name.toLowerCase()] = m.data; dirty = true; }
      return;
    }
    if (!ALLOWED.has(m.t)) return;
    if (m.t === 'block' && !(m.id >= 0 && m.id < BC.NBLOCKS && m.id !== 61 && m.id !== 62)) return; // stairwells come only from the dungeon
    sim.handle(id, m);
    if (m.t[0] === 'v' || m.t[0] === 'p' || m.t === 'kit' || m.t === 'stairs' || m.t === 'hit') dirty = true;
  });
  ws.on('close', () => {
    if (id === null || !conns.has(id) || conns.get(id).ws !== ws) return;
    conns.delete(id); sim.removePlayer(id);
    send({ t: 'msg', text: name + ' left the game' }, null);
    console.log(new Date().toISOString(), 'leave', name);
  });
});

// ---------------------------------------------------------------- loop
let last = Date.now();
setInterval(() => {
  const now = Date.now(), dt = Math.min(0.1, (now - last) / 1000); last = now;
  if (conns.size) sim.tick(dt);
}, 50);
setInterval(() => { if (dirty) saveWorld(); }, 30000);
const stop = () => { try { saveWorld(); } catch (e) { console.error(e); } process.exit(0); };
process.on('SIGTERM', stop); process.on('SIGINT', stop);
server.listen(cfg.port, () => console.log('BlockCraft on port ' + cfg.port + ', seed ' + seed + ', spawn ' + JSON.stringify(spawn)));
EOF

test-client.js: the two-player check now also walks to the shrine, goes down to Floor 1 and climbs back up.

cat > /root/blockcraft/test-client.js <<'EOF'
'use strict';
// Smoke test, run inside CT 127: node test-client.js
// Two fake players join on 127.0.0.1, one places a block, the other must see it.
const WebSocket = require('ws');
const cfg = require('./config.json');
const url = 'ws://127.0.0.1:' + cfg.port + '/blockcraft/ws';
function player(name) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(url), p = { ws, name, msgs: [] };
    ws.on('open', () => ws.send(JSON.stringify({ t: 'join', name, code: cfg.code })));
    ws.on('message', d => { const m = JSON.parse(d); p.msgs.push(m); if (m.t === 'welcome') { p.welcome = m; resolve(p); } if (m.t === 'error') reject(new Error(m.text)); });
    ws.on('error', reject);
    setTimeout(() => reject(new Error('timeout ' + name)), 5000);
  });
}
(async () => {
  const bad = await new Promise(res => {
    const ws = new WebSocket(url);
    ws.on('open', () => ws.send(JSON.stringify({ t: 'join', name: 'Luca', code: 'wrong' })));
    ws.on('message', d => { res(JSON.parse(d)); ws.close(); });
  });
  console.log('wrong code ->', bad.t, bad.text);
  const a = await player('admin'), b = await player('Luca');
  console.log('joined', a.welcome.id, b.welcome.id, 'seed', a.welcome.seed, 'spawn', JSON.stringify(a.welcome.spawn));
  const s = a.welcome.spawn, x = Math.floor(s.x) + 2, y = Math.floor(s.y) + 3, z = Math.floor(s.z);
  a.ws.send(JSON.stringify({ t: 'move', x: s.x, y: s.y, z: s.z, yaw: 0, pitch: 0 }));
  a.ws.send(JSON.stringify({ t: 'block', x, y, z, id: 49 }));
  await new Promise(r => setTimeout(r, 600));
  const seen = b.msgs.some(m => m.t === 'blocks' && m.l.some((v, i) => i % 4 === 0 && m.l[i] === x && m.l[i + 1] === y && m.l[i + 2] === z && m.l[i + 3] === 49));
  const snaps = b.msgs.filter(m => m.t === 'snap');
  console.log('B saw the block:', seen, '| snapshots:', snaps.length, '| players in last snap:', snaps.length ? snaps[snaps.length - 1].players.map(p => p[1]).join(',') : '-');
  a.ws.send(JSON.stringify({ t: 'block', x, y, z, id: 0 }));
  await new Promise(r => setTimeout(r, 300));
  // vehicles: A sets down an airplane, B may not get in, A flies it, B sees it move, A picks it back up
  const wait = ms => new Promise(r => setTimeout(r, ms)), last = p => p.msgs.filter(m => m.t === 'snap').pop();
  a.ws.send(JSON.stringify({ t: 'vplace', kind: 'plane', x: s.x + 5, y: s.y + 20, z: s.z, yaw: 0 }));
  await wait(400);
  const pv = last(b).vehicles.find(v => v[9] === a.name);
  b.ws.send(JSON.stringify({ t: 'vride', id: pv[0] })); a.ws.send(JSON.stringify({ t: 'vride', id: pv[0] }));
  await wait(300);
  const refused = b.msgs.some(m => m.t === 'msg' && /admin's/.test(m.text)), rideOk = a.msgs.some(m => m.t === 'vride');
  a.ws.send(JSON.stringify({ t: 'vmove', id: pv[0], x: s.x + 9, y: s.y + 30, z: s.z + 3, yaw: 1, pitch: 0.2, roll: 0.1 }));
  await wait(400);
  const bv = last(b).vehicles.find(v => v[0] === pv[0]), flew = bv && bv[2] === s.x + 9 && bv[8] === a.welcome.id;
  a.ws.send(JSON.stringify({ t: 'vleave', id: pv[0], x: s.x + 9, y: s.y, z: s.z + 3 }));
  b.ws.send(JSON.stringify({ t: 'vpickup', id: pv[0] })); await wait(200);
  a.ws.send(JSON.stringify({ t: 'vpickup', id: pv[0] })); await wait(400);
  const back = a.msgs.some(m => m.t === 'drop' && m.items[0][0] === 144), gone = !last(b).vehicles.some(v => v[0] === pv[0]);
  const vOK = refused && rideOk && flew && back && gone;
  console.log('vehicle: placed', !!pv, '| other kid refused', refused, '| owner got in', rideOk, '| B saw it fly', flew, '| picked up', back, '| gone', gone);
  // treasure: look inside the nearest castle's treasure chest (only looks, takes nothing)
  const BC = require('./public/engine.js');
  let near = null;
  for (let r = -3; r <= 3; r++) for (let q = -3; q <= 3; q++) {
    const c = BC.castleAt(Math.floor(s.x / BC.REG) + r, Math.floor(s.z / BC.REG) + q, a.welcome.seed);
    if (c && (!near || Math.hypot(c.x0 - s.x, c.z0 - s.z) < Math.hypot(near.x0 - s.x, near.z0 - s.z))) near = c;
  }
  let tOK = !near;
  if (near) {
    const k = (near.x0 + 10) + ',' + (near.base + 1) + ',' + (near.z0 + 14);
    a.ws.send(JSON.stringify({ t: 'chest', k })); await wait(400);
    const ch = a.msgs.filter(m => m.t === 'chest' && m.k === k).pop();
    tOK = !!ch && ch.slots.length > 0;
    console.log('treasure: nearest is a', BC.CASTLES[near.type].name, 'at', near.x0 + 10, near.z0 + 10, '| chest has', ch ? ch.slots.length : 0, 'kinds of things | kit castles:', (a.welcome.owned || []).length);
  }
  // dungeon: walk to the shrine by spawn, take the stairs down to Floor 1, then the Way Up back
  const e = a.welcome.dungeon;
  let dOK = false;
  if (e) {
    a.ws.send(JSON.stringify({ t: 'move', x: e.x + 0.5, y: e.y + 1, z: e.z + 2.5, yaw: 0, pitch: 0 })); await wait(200);
    a.ws.send(JSON.stringify({ t: 'stairs', x: e.x, y: e.y, z: e.z })); await wait(500);
    const down = a.msgs.filter(m => m.t === 'tp').pop();
    if (down && down.floor === 1) {
      a.ws.send(JSON.stringify({ t: 'move', x: down.x, y: down.y, z: down.z, yaw: 0, pitch: 0 })); await wait(200);
      a.ws.send(JSON.stringify({ t: 'stairs', x: Math.floor(down.x), y: down.y, z: Math.floor(down.z) - 2 })); await wait(500);
      const up = a.msgs.filter(m => m.t === 'tp').pop();
      dOK = up !== down && up.floor === 0;
    }
    console.log('dungeon: entrance at', e.x, e.y, e.z, '| went down to Floor', down ? down.floor : '-', '| came back up', dOK);
  }
  a.ws.close(); b.ws.close();
  setTimeout(() => process.exit(seen && vOK && tOK && dOK ? 0 : 1), 300);
})().catch(e => { console.error('FAIL', e.message); process.exit(1); });
EOF

3. Push the files into CT 127 and restart

It copies the game files in, restarts the server and prints active. The family code, the name list and the saved world are not touched; the shrine is built next to the spawn point the first time the new server starts. Anyone playing gets disconnected and the game rejoins by itself:

bash /root/blockcraft/04-push.sh

4. Check it

Copy the new test into CT 127:

pct push 127 /root/blockcraft/test-client.js /opt/blockcraft/test-client.js

Run it. Healthy output ends with dungeon: entrance at ... | went down to Floor 1 | came back up true:

pct exec 127 -- node /opt/blockcraft/test-client.js

The X, Y and Z it prints are where the shrine is. On a tablet, turn on Show position in the pause menu and walk there from the start point (it is about 7 blocks away).

Troubleshooting: where the logs are

The game server

  • Where: the systemd journal for blockcraft inside CT 127 (blockcraft).
  • Open: Proxmox host shell.
pct exec 127 -- journalctl -u blockcraft -n 50 --no-pager
  • Look for: BlockCraft on port 8080, seed ..., spawn {...} after the restart. A JavaScript stack trace followed by blockcraft.service: Main process exited means a file didn't paste completely; paste it again and rerun 04-push.sh.

Dungeon progress and pets

  • Where: /opt/blockcraft/data/world.json in CT 127: dungeon has the shrine's position, which floors have been opened (unlocked) and when each boss was last beaten; pets has every player's pet with its name and level.
  • Open: Proxmox host shell:
pct exec 127 -- node -e "const w=require('/opt/blockcraft/data/world.json');console.log(JSON.stringify({dungeon:w.dungeon,pets:w.pets},null,1))"
  • Each player's level, class, points and achievements are saved with their bag under players in the same file.

In the game itself

  • Tapping the stairwell does nothing: stand right next to it (within a few blocks) and tap the glowing top.
  • No stairs in the boss room: the boss has to be beaten first; it appears when you walk in. If it was beaten in the last 2 minutes, the stairs are already there in the middle of the room.
  • The pet is gone: it reappears when you rejoin, or tap the HERO screen to check you still have one. Pets stay with their owner's name, not the tablet.
  • Too dark to see: the dungeon has a dim light everywhere; the Explorer class and the Night Vision potion make it much brighter.
  • No achievements: they are only earned in Survival.

Notes

  • Tested: the engine on its own (the shrine, going down and up the stairwells, the maze, the Zombie King appearing, beating it opening the stairs, going down to Floor 2, the Way Up, getting and renaming a pet), a single-player world in the browser (the shrine, the purple and green stairwells, the dark lit floors, the "Going Down" and "Phew!" achievements, levelling up, opening a Bronze loot box, the Puppy Basket and Buddy following along, the HERO screen, the boss health bar and the boss reward), and on the live family server with the test above. Not yet tried on the Fire 7 tablets.
  • A bug found while testing: the shrine sets some blocks twice (the platform, then the stairwell on top). In single player, the page and the simulation share one world, and replaying that list of changes made the list grow forever and froze the page on load. Fixed in two places: the simulation now hands over a fresh list, and the page no longer replays changes it already has in single player.
  • Kid-friendly choices: safe rooms on every floor, pets that can't get hurt, bosses that only fight when you walk into their room, and nothing lost when you die (you keep your bag, armor, levels and achievements).
  • Ideas for later: more boss kinds, fan mail from the announcer, a trading shop in the safe rooms, and showing each player's class colour on their name tag.