/* ==========================================================================
   World — the fenced plot, its tiles, the route overlay and the robot.

   Everything here is laid out in tile units so that swapping the flat CSS
   tiles for a sprite sheet later is a change of `background` on .tile /
   .tile__prop / .unit and nothing else.
   ========================================================================== */

/* Just something for the banner to be positioned against. The plot used to
   sit in a wooden frame; it sits on the page now. */
.board__frame { position: relative; }

.playfield {
  --cols: 10;
  --rows: 10;
  /* A TILE IS ALWAYS THE SAME NUMBER OF PIXELS.
     
     Not scaled to fit, not zoomed: 56px on a 3x3 plot and 56px on a 20x20
     one. The plot grows and shrinks with the stage; the tile never does.

     This is for the sprites that are coming. Pixel art at a non-integer
     scale shimmers — edges land between device pixels and every frame picks
     a slightly different rounding — and `image-rendering: pixelated` cannot
     save art that is being drawn at 43.7px one moment and 56px the next.
     Fixing the tile is what makes that whole class of problem not exist.

     The aspect ratio comes from the plot rather than being pinned to a
     square, which is what was squashing every stage that was not 10x10.

     The size itself is `--tile-size` in tokens.css, shared with the builder
     and the asset sheet. It was a second 56px living here, which is two
     numbers meaning one thing and one of them going stale.

     A plot too big for the space scrolls. That is the deliberate trade: the
     alternative is shrinking the art, and the art is the point. */
  /* Positioned inside the board and moved by the camera. `translate` and
     `scale` are separate properties rather than one `transform`, so the
     camera can move the plot without knowing whether the map is open. */
  position: absolute;
  left: 0;
  top: 0;
  aspect-ratio: var(--cols) / var(--rows);
  width: calc(var(--tile-size) * var(--cols));
  transform-origin: 0 0;
  transition: translate var(--tick) linear, scale 220ms var(--ease-out);
  border-radius: var(--radius-sm);
  /* Deliberately NOT clipped. The robot's thought bubble stands above its own
     tile, so a robot dozing on the top row had its bubble cut off at the edge
     of the plot. The ground layer clips the tiles instead, and an <svg>
     already clips itself to its viewBox, so nothing else needed it.

     The hairline is an OUTER ring rather than an inset one for the same
     reason it always should have been: an inset shadow paints under an
     element's children, and the ground layer covers the whole plot. */
  box-shadow: 0 0 0 1px var(--page-line);
}

/* --- Ground layer -------------------------------------------------------- */

.playfield__ground {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: var(--radius-sm);
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
  grid-template-rows: repeat(var(--rows), 1fr);
  /* Sprite tiles, when they arrive, must not be smoothed. */
  image-rendering: pixelated;
}

/* Ground is deliberately flat — a single colour with a fine surface texture
   over it, and no gradient, so tiles read as ground rather than as buttons.
   A hairline along two edges keeps the grid legible where neighbouring tiles
   land on the same shade.

   A ground type sets three things and nothing else: `--ground` (the colour of
   the tile), `--ground-line` (its hairline) and `--ground-grain` (its
   surface, as background-image layers). Every type carries two shades, mixed
   across the plot by `tileShade`. */
.tile {
  position: relative;
  /* Tile art is drawn as a fraction of a tile, and some of the properties it
     needs — `border-width` above all — refuse a percentage but accept
     container units. Making every tile its own container turns `cqmin` into
     "percent of this tile" everywhere inside it.

     Safe because a tile is a `1fr` grid item: its size has always come from
     the grid rather than from its contents, which is exactly the condition
     `container-type: size` imposes. */
  container-type: size;
  background-color: var(--ground);
  background-image: var(--ground-grain, none);
  box-shadow: inset -1px -1px 0 var(--ground-line);
}

/* Grass: fine blades, crossed. */
.tile[data-ground="grass"] {
  --cut: var(--soil-lit);
  --cut-deep: var(--soil-deep);
  --ground: var(--grass-lit);
  --ground-line: rgba(30, 60, 26, .42);
  --ground-grain:
    repeating-linear-gradient(78deg, rgba(255, 255, 255, .10) 0 1px, transparent 1px 5px),
    repeating-linear-gradient(-78deg, rgba(22, 58, 24, .13) 0 1px, transparent 1px 6px);
}

.tile[data-ground="grass"][data-shade="1"] { --ground: var(--grass); }

/* Sand: shallow wind ripples. */
.tile[data-ground="sand"] {
  --cut: var(--sand-deep);
  --cut-deep: var(--dirt-deep);
  --ground: var(--sand-lit);
  --ground-line: rgba(150, 116, 58, .4);
  --ground-grain:
    repeating-linear-gradient(96deg, rgba(255, 255, 255, .34) 0 1px, transparent 1px 6px),
    repeating-linear-gradient(84deg, rgba(150, 116, 58, .16) 0 1px, transparent 1px 9px);
}

.tile[data-ground="sand"][data-shade="1"] { --ground: var(--sand); }

/* Dirt: turned earth, clods and furrows. */
.tile[data-ground="dirt"] {
  --cut: var(--dirt);
  --cut-deep: var(--dirt-deep);
  --ground: var(--dirt-lit);
  --ground-line: rgba(70, 46, 24, .5);
  --ground-grain:
    radial-gradient(circle closest-side, rgba(64, 42, 22, .3) 0 60%, transparent 64%) 18% 26% / 22% 22% no-repeat,
    radial-gradient(circle closest-side, rgba(64, 42, 22, .24) 0 60%, transparent 64%) 74% 62% / 18% 18% no-repeat,
    radial-gradient(circle closest-side, rgba(255, 240, 220, .18) 0 60%, transparent 64%) 46% 82% / 16% 16% no-repeat,
    repeating-linear-gradient(88deg, rgba(64, 42, 22, .12) 0 1px, transparent 1px 7px);
}

.tile[data-ground="dirt"][data-shade="1"] { --ground: var(--dirt); }

/* Concrete: poured slab, fine aggregate. */
.tile[data-ground="concrete"] {
  --cut: var(--concrete);
  --cut-deep: var(--concrete-deep);
  --ground: var(--concrete-lit);
  --ground-line: rgba(90, 88, 82, .5);
  --ground-grain:
    radial-gradient(circle closest-side, rgba(90, 88, 82, .22) 0 60%, transparent 64%) 28% 34% / 12% 12% no-repeat,
    radial-gradient(circle closest-side, rgba(90, 88, 82, .16) 0 60%, transparent 64%) 68% 70% / 10% 10% no-repeat,
    repeating-linear-gradient(92deg, rgba(255, 255, 255, .16) 0 1px, transparent 1px 8px),
    repeating-linear-gradient(2deg, rgba(90, 88, 82, .08) 0 1px, transparent 1px 11px);
}

.tile[data-ground="concrete"][data-shade="1"] { --ground: var(--concrete); }

/* Ice: sheet ice, with hairline cracks and a low sheen. */
.tile[data-ground="ice"] {
  --cut: var(--ice-deep);
  --cut-deep: var(--iceblock-cast);
  --ground: var(--ice-lit);
  --ground-line: rgba(108, 152, 176, .45);
  --ground-grain:
    linear-gradient(122deg, transparent 0 42%, rgba(255, 255, 255, .55) 42% 46%, transparent 46%),
    linear-gradient(28deg, transparent 0 66%, rgba(140, 180, 205, .35) 66% 68%, transparent 68%),
    repeating-linear-gradient(100deg, rgba(255, 255, 255, .22) 0 1px, transparent 1px 10px);
}

.tile[data-ground="ice"][data-shade="1"] { --ground: var(--ice); }

/* --- Hazards -------------------------------------------------------------
   Ground the robot can walk onto but never walk off. They sit in the prop
   layer, on top of whatever ground the plot is made of.
   ------------------------------------------------------------------------ */

/* Lava — and where lava touches lava, ONE lake rather than a tray of tiles.

   The trick is that no layer here is positioned against the tile. Every
   pattern is anchored to `--col` / `--row`, so a tile shows the slice of a
   much larger pool that its own grid position happens to fall on, and two
   neighbours show two ADJACENT slices of one pattern instead of two copies of
   the same square. Nothing is stitched together and no tile knows it has
   neighbours — they simply agree on where the pool is.

   That is what killed the blocky look. The old fill was a per-tile gradient
   with two flare blobs pinned to the tile's own corners, so a lake was
   visibly a grid of identical squares with a hard step at every seam.

   The blob periods are 3, 4, 5 and 7 tiles and deliberately co-prime: they
   only all line up again every 420 tiles, so nothing repeats on any plot.
   Each blob is small enough to fade out well inside its own cell, because a
   blob clipped at the cell edge is a hard line every few tiles — the same
   fault one level up.

   The one thing that must NOT vary per tile is brightness. An animation
   phase-shifted by position looks like a wave in a diagram and like flashing
   squares on a lake, so `molten` runs in step everywhere and the sense of
   movement comes from the drift and the bubbles instead, both of which are
   continuous across the seam. */
.tile[data-hazard="lava"] {
  --pool-a: calc(300cqw) calc(300cqh);
  --pool-b: calc(400cqw) calc(400cqh);
  --pool-c: calc(500cqw) calc(500cqh);
  --pool-d: calc(700cqw) calc(700cqh);

  /* Where this tile sits in the pool. Every layer starts from here. */
  --pool-at: calc(var(--col) * -100cqw) calc(var(--row) * -100cqh);

  /* The shore. A thin cooled line, then a soft dark band fading inward —
     lava chills where it meets rock, so the edge reads as crust rather than
     as a cut, and a soft edge forgives the corner a hard line would not. */
  --shore-n: inset 0 1.5px 0 var(--lava-crust), inset 0 10cqmin 8cqmin -8cqmin var(--lava-chill);
  --shore-e: inset -1.5px 0 0 var(--lava-crust), inset -10cqmin 0 8cqmin -8cqmin var(--lava-chill);
  --shore-s: inset 0 -1.5px 0 var(--lava-crust), inset 0 -10cqmin 8cqmin -8cqmin var(--lava-chill);
  --shore-w: inset 1.5px 0 0 var(--lava-crust), inset 10cqmin 0 8cqmin -8cqmin var(--lava-chill);

  --lava-chill: color-mix(in srgb, var(--lava-crust) 78%, transparent);
}

/* An edge facing more lava is not a shore. */
.tile[data-hazard="lava"][data-join~="n"] { --shore-n: 0 0 transparent; }
.tile[data-hazard="lava"][data-join~="e"] { --shore-e: 0 0 transparent; }
.tile[data-hazard="lava"][data-join~="s"] { --shore-s: 0 0 transparent; }
.tile[data-hazard="lava"][data-join~="w"] { --shore-w: 0 0 transparent; }

.tile[data-hazard="lava"] .tile__prop {
  inset: 0;
  background-image:
    radial-gradient(22% 22% at 50% 44%, var(--lava-lit) 0%, transparent 72%),
    radial-gradient(20% 23% at 44% 56%, var(--lava-deep) 0%, transparent 74%),
    radial-gradient(19% 19% at 56% 48%, var(--lava-lit) 0%, transparent 70%),
    radial-gradient(21% 20% at 48% 52%, var(--lava-deep) 0%, transparent 72%),
    linear-gradient(var(--lava), var(--lava));
  background-size: var(--pool-a), var(--pool-b), var(--pool-c), var(--pool-d), auto;
  background-position: var(--pool-at), var(--pool-at), var(--pool-at), var(--pool-at), 0 0;
  background-repeat: repeat;
  box-shadow: var(--shore-n), var(--shore-e), var(--shore-s), var(--shore-w);
  animation: molten 7s ease-in-out infinite;
}

/* Convection: a second pool of light, sliding under the first.

   It drifts by exactly one period, so the loop closes on itself with nothing
   to see at the join, and every tile drifts by the same amount at the same
   moment — which is what keeps the surface whole while it moves. */
.tile[data-hazard="lava"] .tile__prop::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image:
    radial-gradient(24% 21% at 46% 50%, rgba(255, 214, 128, .85) 0%, transparent 74%),
    radial-gradient(18% 22% at 56% 44%, rgba(255, 168, 64, .7) 0%, transparent 76%);
  background-size: var(--pool-a);
  background-repeat: repeat;
  mix-blend-mode: screen;
  animation: lava-drift 19s linear infinite;
}

/* Bubbles, rising forever.

   The cell is 62% of a tile, which is deliberately not a whole fraction of
   one: anchored to `--col` the phase lands somewhere different in every
   column, so the bubbles never queue up into rows. They rise by exactly one
   cell, so this too loops seamlessly. */
.tile[data-hazard="lava"] .tile__prop::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image:
    radial-gradient(circle 1.7cqmin at 21% 31%, rgba(255, 249, 226, .95) 0 60%, transparent 63%),
    radial-gradient(circle 1.1cqmin at 62% 13%, rgba(255, 238, 196, .9) 0 60%, transparent 63%),
    radial-gradient(circle 2cqmin   at 79% 67%, rgba(255, 244, 210, .8) 0 60%, transparent 63%),
    radial-gradient(circle 1.3cqmin at 37% 83%, rgba(255, 232, 182, .9) 0 60%, transparent 63%);
  background-size: 62cqw 62cqh;
  background-repeat: repeat;
  animation: lava-bubbles 7.5s linear infinite;
}

@keyframes lava-drift {
  from { background-position: calc(var(--col) * -100cqw) calc(var(--row) * -100cqh); }
  to   { background-position: calc(var(--col) * -100cqw - 300cqw) calc(var(--row) * -100cqh - 300cqh); }
}

@keyframes lava-bubbles {
  from { background-position: calc(var(--col) * -100cqw) calc(var(--row) * -100cqh); }
  to   { background-position: calc(var(--col) * -100cqw) calc(var(--row) * -100cqh - 62cqh); }
}

@keyframes molten {
  0%, 100% { filter: brightness(1); }
  50%      { filter: brightness(1.16); }
}

/* Hole: a shaft cut through the floor — and, where holes touch, a chasm.

   The four walls are a real CSS BORDER rather than four wedges of a conic
   gradient, and that choice is the whole feature. A border already mitres its
   corners along the diagonal, which is what the conic bought; but a border
   with one side set to zero also SQUARES OFF the two beside it, so the walls
   either side run the full width of the tile and meet their twins next door.
   Take a side away and two pits become one pit. The conic could not do that:
   its seams always ran to the tile's own corners, so a merged edge left a
   triangular notch of the wrong wall at each end.

   So a tile draws a wall on an edge only when there is ground on the other
   side of it. `data-join` names the edges that face more hole, and each one
   named takes its wall to zero. Nothing here knows how big the chasm is or
   what shape it is; every tile answers for its own four edges and the shape
   falls out of that.

   Which wall gets which value is the other half of it, and it is the REVERSE
   of a raised object. With light from the top-left, the inner faces of the
   top and left walls point away from it and fall into shadow, while the
   bottom and right walls turn toward it and catch it. A bump is lit top-left
   and shaded bottom-right; a hole is the other way round, and getting it
   backwards is precisely what makes a hole look like it is popping out.

   For the same reason there is no drop shadow and no lit top edge. Both say
   "this is sitting on the floor", which is the opposite of the truth. The
   walls are drawn in the terrain's own `--cut` pair, so the shaft is cut
   through whatever the plot is made of. */
.tile[data-hazard="hole"] {
  /* The four wall faces. Named rather than written inline because the inner
     corner below has to paint the same colours as the walls it continues. */
  --face-n: color-mix(in srgb, var(--cut-deep) 60%, black);  /* darkest */
  --face-e: color-mix(in srgb, var(--cut) 55%, var(--cut-deep));
  --face-s: var(--cut);                                      /* lit     */
  --face-w: var(--cut-deep);

  --corner-nw: none;
  --corner-ne: none;
  --corner-se: none;
  --corner-sw: none;

  /* 17% of the tile. `border-width` refuses a percentage but takes container
     units, and every tile is its own query container — see `.tile`. */
  --wall: 17cqmin;
  --wall-n: var(--wall);
  --wall-e: var(--wall);
  --wall-s: var(--wall);
  --wall-w: var(--wall);

  /* The floor darkening just inside each wall, which is what gives the shaft
     its depth. Directional rather than an all-round glow, for the same reason
     the walls are: an edge that is not there must not be shaded either, or
     every seam in a chasm draws itself back in as a dark line. */
  --seam-n: inset 0 3px 3px -3px rgba(0, 0, 0, .55);
  --seam-e: inset -3px 0 3px -3px rgba(0, 0, 0, .55);
  --seam-s: inset 0 -3px 3px -3px rgba(0, 0, 0, .55);
  --seam-w: inset 3px 0 3px -3px rgba(0, 0, 0, .55);
}

/* The inner corner — the one thing the border cannot do for itself.

   Where a chasm bends back on itself, BOTH edges of that corner are joined,
   so neither draws a wall. But the wall running along the outside of the bend
   still has to get round it, and with both borders at zero there is nothing
   there: a square notch of bare floor opens up at the turn.

   So each bend gets a wall-sized square painted back into it, split along the
   diagonal in the same two colours the walls arriving from either side are
   already using — which is precisely the mitre a border would have made if
   the corner had been an ordinary one. Four background layers, one per
   corner, because a tile in the middle of a plus-shaped chasm needs all four
   at once.

   The square is placed against the prop's PADDING box, and that is exact
   rather than lucky: a corner is only ever filled when both its edges are
   joined, and a joined edge has a zero border, so the padding box already
   reaches that corner of the tile. */
.tile[data-hazard="hole"][data-corner~="nw"] { --corner-nw: linear-gradient(45deg,  var(--face-n) 0 50%, var(--face-w) 50% 100%); }
.tile[data-hazard="hole"][data-corner~="ne"] { --corner-ne: linear-gradient(135deg, var(--face-e) 0 50%, var(--face-n) 50% 100%); }
.tile[data-hazard="hole"][data-corner~="se"] { --corner-se: linear-gradient(45deg,  var(--face-e) 0 50%, var(--face-s) 50% 100%); }
.tile[data-hazard="hole"][data-corner~="sw"] { --corner-sw: linear-gradient(135deg, var(--face-s) 0 50%, var(--face-w) 50% 100%); }

.tile[data-hazard="hole"] .tile__prop::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: var(--corner-nw), var(--corner-ne), var(--corner-se), var(--corner-sw);
  background-position: 0 0, 100% 0, 100% 100%, 0 100%;
  background-size: var(--wall) var(--wall);
  background-repeat: no-repeat;
}

/* An edge facing more hole is an edge that does not exist. */
.tile[data-hazard="hole"][data-join~="n"] { --wall-n: 0; --seam-n: 0 0 transparent; }
.tile[data-hazard="hole"][data-join~="e"] { --wall-e: 0; --seam-e: 0 0 transparent; }
.tile[data-hazard="hole"][data-join~="s"] { --wall-s: 0; --seam-s: 0 0 transparent; }
.tile[data-hazard="hole"][data-join~="w"] { --wall-w: 0; --seam-w: 0 0 transparent; }

.tile[data-hazard="hole"] .tile__prop {
  inset: 0;
  border-style: solid;
  border-width: var(--wall-n) var(--wall-e) var(--wall-s) var(--wall-w);
  border-color: var(--face-n) var(--face-e) var(--face-s) var(--face-w);
  /* The floor is the padding box, so on a merged edge it runs to the tile
     edge on its own and joins the floor next door with no seam to hide. */
  background: var(--pit-void);
  box-shadow: var(--seam-n), var(--seam-e), var(--seam-s), var(--seam-w);
}

/* Doors, and the keys that open them.
 *
 * A door is a BLOCKER until you hold its colour, so it is drawn like one — a
 * solid thing standing on the tile with a lit top edge and a cast shadow,
 * because the whole point is that it reads as "not through here" at a glance.
 * What tells it apart from a boulder is the colour and the seam down the
 * middle: something made, with a way through it, currently shut.
 *
 * Open, the two leaves slide back into the frame and the tile becomes ordinary
 * ground with a colour around its edge. They open the moment the key is in
 * hand rather than when the robot arrives, which is what makes the key legible
 * — every door of that colour comes open at once and the route appears.
 *
 * The colour comes off `data-keys`, which is the door's PRICE. Keys are
 * generic, so there is nothing to match — blue costs one, red costs two.
 */
.tile--door { --lock: var(--lock-blue); --lock-deep: var(--lock-blue-deep); }
.tile--key  { --lock: var(--lock-steel); --lock-deep: var(--lock-steel-deep); }

/* A door's colour is its PRICE, not an identity to match a key against. Keys
   are generic, so blue is a door that costs one and red is a door that costs
   two -- and a player learns that in one stage. */
[data-keys="2"] { --lock: var(--lock-red); --lock-deep: var(--lock-red-deep); }

[data-lock="red"]    { --lock: var(--lock-red);    --lock-deep: var(--lock-red-deep); }
[data-lock="yellow"] { --lock: var(--lock-yellow); --lock-deep: var(--lock-yellow-deep); }
[data-lock="green"]  { --lock: var(--lock-green);  --lock-deep: var(--lock-green-deep); }

/* The frame is part of the floor and stays whether the door is open or shut. */
.tile--door .tile__prop {
  inset: 0;
  background: var(--ground);
  box-shadow:
    inset 0 0 0 3px var(--lock-deep),
    inset 0 0 0 5px color-mix(in srgb, var(--lock) 45%, transparent);
}

/* The two leaves. Shut, they meet in the middle; open, they are gone into the
   frame. One element for both, because they are mirror images. */
.tile--door .tile__prop::before,
.tile--door .tile__prop::after {
  content: "";
  position: absolute;
  top: 5%;
  bottom: 5%;
  width: 46%;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--lock) 82%, white) 0%,
    var(--lock) 42%,
    color-mix(in srgb, var(--lock) 70%, var(--lock-deep)) 100%);
  box-shadow:
    inset 0 0 0 2px var(--lock-deep),
    inset 0 2px 0 rgba(255, 255, 255, .35),
    0 3px 0 color-mix(in srgb, var(--lock-deep) 70%, transparent);
  transition: translate 420ms var(--ease-out), opacity 420ms var(--ease-out);
}

.tile--door .tile__prop::before { left: 2%; border-radius: 2px 0 0 2px; }
.tile--door .tile__prop::after  { right: 2%; border-radius: 0 2px 2px 0; }

/* Open: each leaf slides into its own side of the frame and fades as it goes. */
.tile--door[data-open="true"] .tile__prop::before { translate: -96% 0; opacity: 0; }
.tile--door[data-open="true"] .tile__prop::after  { translate: 96% 0; opacity: 0; }

/* Standing in a wall that runs up and down, the leaves turn with it — top and
   bottom rather than left and right — so they slide INTO the wall instead of
   out across the open ground either side. */
.tile--door[data-swing="up"] .tile__prop::before,
.tile--door[data-swing="up"] .tile__prop::after {
  left: 5%;
  right: 5%;
  top: auto;
  bottom: auto;
  width: auto;
  height: 46%;
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--lock) 82%, white) 0%,
    var(--lock) 42%,
    color-mix(in srgb, var(--lock) 70%, var(--lock-deep)) 100%);
}

.tile--door[data-swing="up"] .tile__prop::before { top: 2%; border-radius: 2px 2px 0 0; }
.tile--door[data-swing="up"] .tile__prop::after  { bottom: 2%; border-radius: 0 0 2px 2px; }

.tile--door[data-swing="up"][data-open="true"] .tile__prop::before { translate: 0 -96%; }
.tile--door[data-swing="up"][data-open="true"] .tile__prop::after  { translate: 0 96%; }

/* A key card, lying on the ground.
 *
 * Flat on the floor and slightly turned, so it reads as dropped rather than
 * installed — the opposite of the door, which is built into the tile. */
.tile--key .tile__prop {
  inset: 26% 22%;
  border-radius: 2px;
  rotate: -8deg;
  background: linear-gradient(150deg,
    color-mix(in srgb, var(--lock) 85%, white) 0%,
    var(--lock) 55%,
    var(--lock-deep) 100%);
  box-shadow:
    inset 0 0 0 1.5px var(--lock-deep),
    inset 0 2px 0 rgba(255, 255, 255, .4),
    0 2px 3px rgba(0, 0, 0, .4);
  animation: cell-bob 2.8s ease-in-out infinite;
}

/* The chip, so it is a card rather than a coloured rectangle. */
.tile--key .tile__prop::after {
  content: "";
  position: absolute;
  left: 12%;
  top: 26%;
  width: 30%;
  height: 34%;
  border-radius: 1px;
  background: color-mix(in srgb, var(--lock-deep) 55%, var(--parchment));
  box-shadow: inset 0 0 0 1px var(--lock-deep);
}

.tile--key[data-taken="true"] .tile__prop {
  animation: cell-taken 320ms var(--ease-out) forwards;
  pointer-events: none;
}

/* A loose battery cell, lying on the ground.
 *
 * An emoji rather than a drawn asset, on purpose and for now: it is an ITEM,
 * not terrain, and the one thing it must never look like is part of the floor.
 * Something that plainly does not belong to the tile it is on reads as "pick
 * this up" without anything having to explain it.
 *
 * It sits on the ground rather than being set into it — the opposite of the
 * charging plate below, which is the whole difference between a thing you
 * collect and a place you stand. */
.tile--cell .tile__prop {
  inset: 0;
  display: grid;
  place-items: center;
  font-size: 58cqmin;
  line-height: 1;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .45));
  animation: cell-bob 2.4s ease-in-out infinite;
}

.tile--cell .tile__prop::before {
  content: "🔋";
}

/* Taken. The tile stays exactly as it was; only the cell goes. */
.tile--cell[data-taken="true"] .tile__prop {
  animation: cell-taken 320ms var(--ease-out) forwards;
  pointer-events: none;
}

@keyframes cell-bob {
  0%, 100% { transform: translateY(3%); }
  50%      { transform: translateY(-7%); }
}

@keyframes cell-taken {
  0%   { opacity: 1; transform: translateY(0) scale(1); }
  100% { opacity: 0; transform: translateY(-45%) scale(.5); }
}

/* A charging plate, set INTO the floor — the robot drives over it, so it must
   not read as something to walk around.

   Which means the bevel runs the opposite way to every blocker: dark along
   the top inside where the near rim shadows it, lightening toward the bottom
   where the far wall catches the light. It is the same inversion the hole
   needs, one layer shallower — a lit top edge is the single cue that says
   "this stands proud", and it was the only thing making this look solid.

   And no cast shadow, for the same reason. Nothing set into a floor drops
   one. */
.tile--charger .tile__prop {
  inset: 11%;
  border-radius: 3px;
  /* Flat. No gradient at all: a gradient down the plate is a RAMP, and a ramp
     is a surface you are looking at on the slant — the opposite of the flat
     plate set into the floor that this is meant to be.

     All the depth comes from the shading closing in from the edges instead,
     which is an inner drop shadow and reads the same way one does on a
     recessed panel: the surface is flat, the walls around it are not.

     A flat fill is also why there is no directional bias here any more. That
     was only needed to stop a light-centred gradient reading as a dome, and
     with nothing graduating there is no dome to prevent. */
  background: var(--plate);
  box-shadow:
    /* The cut itself, crisp on all four edges. */
    inset 0 0 0 1px rgba(0, 0, 0, .3),
    /* And the shadow the surrounding floor casts into it, equally from
       every side. */
    inset 0 0 5px rgba(0, 0, 0, .32);
}

.tile--charger .tile__prop::after {
  content: "";
  position: absolute;
  inset: 20% 31%;
  background: var(--gold);
  clip-path: polygon(52% 0, 96% 0, 58% 40%, 88% 40%, 20% 100%, 40% 56%, 10% 56%);
  filter: drop-shadow(0 0 3px var(--gold));
  animation: charger 1.8s ease-in-out infinite;
}

@keyframes charger {
  0%, 100% { opacity: .5; }
  50%      { opacity: 1; }
}

/* Where the robot begins: a portal that has already closed.
 *
 * It was a scuffed patch of ground, which said nothing — a plot has plenty of
 * scuffed ground. The robot arrives from somewhere, and the way out is a
 * violet mouth standing open, so the way IN should be the same thing spent:
 * the same violet, lying flat, gone out.
 *
 * Everything about it is the exit's opposite. The exit is bright at the
 * centre and throws rings OUTWARD; this is dark at the centre, its ring has
 * collapsed to a thin line, and the light it has left is a stain on the floor
 * rather than a glow above it. It breathes, very slowly and very slightly,
 * because a portal that shut a moment ago has not quite finished cooling —
 * and it must never compete with the one you are walking towards.
 *
 * Two classes so it outranks the terrains above. Drawn by the tile's own
 * pseudo-elements rather than a prop layer: `paintTiles` gives the start no
 * prop, precisely because the pad is the ground rather than a thing on it.
 */
.tile.tile--home {
  --ground-grain:
    repeating-linear-gradient(90deg, rgba(0, 0, 0, .1) 0 2px, transparent 2px 9px),
    radial-gradient(circle at 50% 50%,
      color-mix(in srgb, var(--portal-deep) 45%, transparent) 0 26%,
      color-mix(in srgb, var(--portal-ring) 30%, transparent) 42%,
      transparent 68%);
}

/* The scorch: where the mouth was, burnt into the floor. */
.tile.tile--home::before {
  content: "";
  position: absolute;
  inset: 26%;
  border-radius: 50%;
  background: radial-gradient(circle,
    color-mix(in srgb, var(--portal-ring) 85%, black) 0 38%,
    color-mix(in srgb, var(--portal-deep) 55%, transparent) 62%,
    transparent 78%);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, .5);
}

/* The last ring, caught closing. */
.tile.tile--home::after {
  content: "";
  position: absolute;
  inset: 21%;
  border-radius: 50%;
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--portal) 55%, transparent),
    0 0 6px 1px color-mix(in srgb, var(--portal) 30%, transparent);
  animation: home-cool 5.2s ease-in-out infinite;
}

@keyframes home-cool {
  0%, 100% { opacity: .45; transform: scale(.94); }
  50%      { opacity: .9; transform: scale(1.02); }
}

/* --- Props --------------------------------------------------------------- */

.tile__prop {
  position: absolute;
  inset: 0;
  display: block;
}

/* Boulders: a lit top face and a cast shadow give the plot its top-down
   three-quarter depth. */
.tile[data-blocker="stone"] .tile__prop {
  inset: 6%;
  border-radius: var(--radius-sm);
  background: linear-gradient(165deg, var(--stone-lit) 0%, var(--stone) 46%, var(--stone-deep) 100%);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, .35),
    inset 0 -3px 0 rgba(0, 0, 0, .28),
    0 3px 0 var(--soil-deep),
    0 5px 8px rgba(35, 25, 12, .4);
}

.tile[data-blocker="stone"] .tile__prop::after {
  content: "";
  position: absolute;
  inset: 22% 54% 52% 20%;
  border-radius: 50%;
  background: rgba(255, 255, 255, .22);
}

/* Metal plate: flat, cold and machined, against the boulder's rounded warmth. */
.tile[data-blocker="metal"] .tile__prop {
  inset: 4%;
  border-radius: 2px;
  background: linear-gradient(145deg, var(--metal-lit) 0%, var(--metal) 46%, var(--metal-deep) 100%);
  box-shadow:
    inset 0 0 0 2px rgba(68, 82, 94, .85),
    inset 0 3px 0 rgba(255, 255, 255, .5),
    inset 0 -3px 0 rgba(28, 38, 48, .38),
    0 3px 0 var(--soil-deep),
    0 5px 8px rgba(35, 25, 12, .38);
}

/* Rivets, one in each corner. */
.tile[data-blocker="metal"] .tile__prop::after {
  content: "";
  position: absolute;
  inset: 13%;
  background-image:
    radial-gradient(circle, rgba(246, 250, 252, .85) 0 24%, rgba(48, 60, 70, .6) 30%, transparent 34%),
    radial-gradient(circle, rgba(246, 250, 252, .85) 0 24%, rgba(48, 60, 70, .6) 30%, transparent 34%),
    radial-gradient(circle, rgba(246, 250, 252, .85) 0 24%, rgba(48, 60, 70, .6) 30%, transparent 34%),
    radial-gradient(circle, rgba(246, 250, 252, .85) 0 24%, rgba(48, 60, 70, .6) 30%, transparent 34%);
  background-size: 26% 26%;
  background-position: 0 0, 100% 0, 0 100%, 100% 100%;
  background-repeat: no-repeat;
}

/* Ice brick: cut from the sheet, glassy, lit from inside. */
.tile[data-blocker="iceblock"] .tile__prop {
  inset: 5%;
  border-radius: 3px;
  background:
    linear-gradient(131deg, rgba(255, 255, 255, .72) 0 24%, transparent 24%),
    linear-gradient(311deg, rgba(255, 255, 255, .4) 0 16%, transparent 16%),
    linear-gradient(168deg, var(--iceblock-lit) 0%, var(--iceblock) 50%, var(--iceblock-deep) 100%);
  box-shadow:
    inset 0 0 0 1px rgba(120, 172, 200, .85),
    inset 0 2px 0 rgba(255, 255, 255, .95),
    inset 0 -3px 0 rgba(96, 146, 178, .5),
    0 3px 0 var(--iceblock-cast),
    0 5px 8px rgba(60, 96, 120, .35);
}

/* --- The way out, and the way across ------------------------------------
   Both are a disc of light set into the floor, big enough to fill the tile
   and cropped by it.

   To cover a square a disc has to clear the DIAGONAL, not the edges: a
   radius of at least 70.7% of the tile. At -24% the disc is 148% across, so
   its radius is 74% and the corners are covered with a little to spare.
   Anything under 70.7% and the corners show through.

   Because the disc fills the tile, every edge treatment it carries is
   outside the square and clipped away — so the tile itself supplies the
   grid hairline and the inner shade, and the disc supplies only the light.
   ------------------------------------------------------------------------ */

.tile--portal,
.tile--teleport,
.tile--gate {
  overflow: hidden;
  box-shadow:
    inset -1px -1px 0 var(--ground-line),
    inset 0 0 0 1px rgba(0, 0, 0, .4),
    inset 0 0 12px rgba(0, 0, 0, .5);
}

.tile--portal .tile__prop,
.tile--teleport .tile__prop {
  inset: -24%;
  border-radius: 50%;
  animation: portal-breathe 3.2s ease-in-out infinite;
}

.tile--portal .tile__prop {
  background: radial-gradient(circle at 50% 44%,
    var(--portal-core) 0 10%,
    var(--portal) 26%,
    var(--portal-deep) 52%,
    var(--portal-ring) 78%);
}

.tile--teleport .tile__prop {
  background: radial-gradient(circle at 50% 44%,
    var(--teleport-core) 0 10%,
    var(--teleport) 26%,
    var(--teleport-deep) 52%,
    var(--teleport-ring) 78%);
}

/* What moves inside the mouth. Pulled well inside the disc so it stays within
   the square once the disc is cropped. */
.tile--portal .tile__prop::after,
.tile--teleport .tile__prop::after {
  content: "";
  position: absolute;
  inset: 26%;
  border-radius: 50%;
  opacity: .85;
}

/* The way out sends rings outward: it is announcing itself. */
.tile--portal .tile__prop::after {
  background: repeating-radial-gradient(circle at 50% 50%,
    transparent 0 12%,
    color-mix(in srgb, var(--portal-core) 55%, transparent) 12% 18%,
    transparent 18% 30%);
  animation: portal-pulse 2.4s ease-in-out infinite;
}

/* The teleport turns inward instead: it pulls you through rather than calling
   you over. Same disc, same size, opposite motion — which is what tells the
   two apart at a glance, colour aside. */
.tile--teleport .tile__prop::after {
  background: conic-gradient(
    from 0turn,
    transparent 0 8%,
    var(--teleport-core) 18%,
    transparent 34%,
    var(--teleport) 52%,
    transparent 68%,
    var(--teleport-core) 84%,
    transparent 96%);
  animation: portal-spin 4.6s linear infinite;
}

/* The gate — the same idea drawn inside out.

   The portal and the teleport are both bright in the middle: a pool of light
   lying in the floor, calling you over. This one is a HOLE in the world with
   a burning rim around it. Dark where they are bright, warm where they are
   cool, and starlight turning somewhere behind the opening.

   Structure is what separates it, not hue. A tile you can tell apart only by
   its colour stops being legible the moment it is small, or the moment it is
   standing on ground that happens to share the hue; light-in-the-middle
   against dark-in-the-middle survives both.

   Unlike those two it is NOT a disc cropped by the square. It fills the tile
   edge to edge and falls back to void before the corners, so the whole square
   goes black and the ring is free to run right out to the edge instead of
   being held inside a circle the tile then cuts. Radii here are fractions of
   the gradient's own radius, which for a circle sized to the farthest corner
   is 70.7% of the tile — so the ring at 62% sits 44% of a tile out, and its
   outer edge at 69% meets the edge of the square almost exactly. */
.tile--gate .tile__prop {
  inset: 0;
  background: radial-gradient(circle at 50% 50%,
    var(--gate-void) 0 30%,
    var(--gate-deep) 44%,
    var(--gate-rim) 54%,
    var(--gate-hot) 62%,
    var(--gate-rim) 69%,
    var(--gate-deep) 75%,
    var(--gate-void) 84%);
  animation: portal-breathe 3.2s ease-in-out infinite;
}

/* Starlight, somewhere through the opening. Slow enough to be movement
   rather than spin — the mouth is what turns, not the sky behind it. */
.tile--gate .tile__prop::before {
  content: "";
  position: absolute;
  inset: 30%;
  border-radius: 50%;
  background:
    radial-gradient(circle 1.1cqmin at 26% 32%, var(--gate-hot) 0 100%, transparent 0),
    radial-gradient(circle .8cqmin  at 68% 24%, var(--gate-rim) 0 100%, transparent 0),
    radial-gradient(circle 1.3cqmin at 74% 66%, var(--gate-hot) 0 100%, transparent 0),
    radial-gradient(circle .9cqmin  at 34% 72%, var(--gate-rim) 0 100%, transparent 0),
    radial-gradient(circle .7cqmin  at 52% 44%, var(--gate-hot) 0 100%, transparent 0);
  animation: portal-spin 26s linear infinite;
}

@keyframes portal-spin {
  to { transform: rotate(1turn); }
}

@keyframes portal-pulse {
  0%, 100% { transform: scale(.86); opacity: .35; }
  50%      { transform: scale(1); opacity: .9; }
}

/* The glow used to sit outside the disc, which is now outside the tile and
   clipped away, so the light itself pulses instead of a halo. */
@keyframes portal-breathe {
  0%, 100% { filter: brightness(1); }
  50%      { filter: brightness(1.2); }
}

/* Where the robot begins — a pad scuffed into whatever it is standing on.
   Two classes so it outranks the terrains above. */
/* --- Trail overlay -------------------------------------------------------
   Drawn in tile units (the viewBox is `0 0 cols rows`), so the centre of
   tile (x, y) is simply (x + .5, y + .5). Only ground the robot has already
   covered is ever drawn here.
   ------------------------------------------------------------------------ */

.playfield__route {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.route__line {
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

.route__line--walked {
  stroke: rgba(255, 255, 255, .8);
  stroke-width: 5;
  filter: drop-shadow(0 1px 1px rgba(35, 25, 12, .5));
}

/* --- The robot ----------------------------------------------------------- */

.unit {
  /* Livery. Override these on a variant, never `.unit__body` itself. */
  --bot-lit: var(--bot-steel-lit);
  --bot-mid: var(--bot-steel);
  --bot-deep: var(--bot-steel-deep);

  /* Whatever the robot looks out of, be it a visor or eyes. */
  --bot-eye: var(--sky);
  --bot-eye-deep: var(--sky-deep);

  /* The lamp on its head, for the liveries that carry one. */
  --bot-lamp: var(--gold);

  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  width: calc(100% / var(--cols));
  height: calc(100% / var(--rows));
  transform: translate(calc(var(--x, 0) * 100%), calc(var(--y, 0) * 100%));
  transition: transform var(--tick) var(--ease-hop);
  will-change: transform;
}

.unit__shadow {
  position: absolute;
  inset: 68% 18% 8% 18%;
  border-radius: 50%;
  background: rgba(30, 45, 20, .42);
  filter: blur(2px);
}

.unit__body {
  position: absolute;
  inset: 12% 14% 16% 14%;
  border-radius: var(--radius-sm);
  background: linear-gradient(165deg, var(--bot-lit) 0%, var(--bot-mid) 52%, var(--bot-deep) 100%);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, .9),
    inset 0 -3px 0 rgba(60, 70, 80, .45),
    0 2px 0 var(--bot-deep);
  animation: bob 2.2s var(--ease-out) infinite;
}

.unit--blue  { --bot-lit: var(--bot-blue-lit);  --bot-mid: var(--bot-blue);  --bot-deep: var(--bot-blue-deep);
               --bot-eye: var(--screen-mint);   --bot-eye-deep: var(--screen-mint-deep); }
.unit--pink  { --bot-lit: var(--bot-pink-lit);  --bot-mid: var(--bot-pink);  --bot-deep: var(--bot-pink-deep);
               --bot-lamp: var(--lamp-pearl); }
.unit--green { --bot-lit: var(--bot-green-lit); --bot-mid: var(--bot-green); --bot-deep: var(--bot-green-deep); }

@keyframes bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-4%); }
}

/* The visor is the face, and where it sits is the whole of the robot's
   heading: full width across the front, wrapped round to one edge in
   profile, and replaced by the vents on its back when it walks away.
   `data-facing` carries the direction of the last step, so these are the
   four views a directional sprite sheet will eventually replace — front,
   left, right, back — with no change to the movement logic. */
.unit__visor {
  position: absolute;
  inset: 26% 20% 44% 20%;
  border-radius: 2px;
  background: linear-gradient(180deg, var(--bot-eye) 0%, var(--bot-eye-deep) 100%);
  box-shadow:
    inset 0 0 0 1px rgba(20, 40, 50, .6),
    0 0 8px color-mix(in srgb, var(--bot-eye) 62%, transparent);
  transition: inset 160ms var(--ease-out), background 160ms var(--ease-out);
}

/* Profile: the visor wraps to the leading edge. */
.unit[data-facing="left"]  .unit__visor { inset: 26% 54% 44% 16%; }
.unit[data-facing="right"] .unit__visor { inset: 26% 16% 44% 54%; }

/* Walking away: no face at all. Each livery has its own back, so you can
   still tell the robots apart from behind. This is the default — louvred
   vents — which the steel one keeps. */
.unit[data-facing="up"] .unit__visor {
  inset: 28% 26% 38% 26%;
  border-radius: 2px;
  background:
    repeating-linear-gradient(180deg, rgba(28, 38, 48, .45) 0 2px, transparent 2px 5px),
    rgba(255, 255, 255, .22);
  box-shadow: inset 0 0 0 1px rgba(28, 38, 48, .4);
}

/* The pink one: a round hatch with a slot. */
.unit--pink[data-facing="up"] .unit__visor {
  inset: 28% 33% 38% 33%;
  border-radius: 50%;
  background:
    linear-gradient(180deg, rgba(255, 255, 255, .45) 0%, rgba(120, 40, 70, .28) 100%),
    var(--bot-lit);
  box-shadow: inset 0 0 0 1px rgba(96, 32, 58, .55), inset 0 -1px 2px rgba(96, 32, 58, .3);
}

.unit--pink[data-facing="up"] .unit__visor::before {
  content: "";
  position: absolute;
  inset: 44% 22%;
  border-radius: 1px;
  background: rgba(96, 32, 58, .5);
}

/* The green one has three eyes rather than a visor. The element becomes the
   middle eye and its two pseudo-elements the outer pair, inheriting their
   fill and rim from it — so the eyes cannot drift apart in colour, and the
   whole row still turns with `data-facing`. */
.unit--green .unit__visor {
  inset: 30% 42% 54% 42%;
  border-radius: 50%;
  background: var(--bot-eye);
  box-shadow:
    inset 0 0 0 1px rgba(20, 40, 50, .6),
    0 0 6px color-mix(in srgb, var(--bot-eye) 62%, transparent);
}

.unit--green .unit__visor::before,
.unit--green .unit__visor::after {
  content: "";
  position: absolute;
  top: 0;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  background: inherit;
  box-shadow: inherit;
}

.unit--green .unit__visor::before { left: -120%; }
.unit--green .unit__visor::after  { left: 120%; }

/* Turning costs it an eye: in profile only the two on the leading side are
   showing, and from behind none are. These must follow the base facing rules
   above, which they match in specificity. */
.unit--green[data-facing="left"]  .unit__visor { inset: 30% 50% 54% 34%; }
.unit--green[data-facing="right"] .unit__visor { inset: 30% 34% 54% 50%; }

.unit--green[data-facing="left"]  .unit__visor::after,
.unit--green[data-facing="right"] .unit__visor::before,
.unit--green[data-facing="up"]    .unit__visor::before,
.unit--green[data-facing="up"]    .unit__visor::after { display: none; }

/* It also has a mouth — a little speaker grille under the eyes. The body's
   pseudo-elements are free on this livery, so it costs no markup. */
.unit--green .unit__body::after {
  content: "";
  position: absolute;
  top: 62%;
  left: 30%;
  right: 30%;
  height: 12%;
  border-radius: 2px;
  background:
    repeating-linear-gradient(90deg, rgba(22, 56, 32, .5) 0 1px, transparent 1px 3px),
    rgba(255, 255, 255, .32);
  box-shadow: inset 0 0 0 1px rgba(22, 56, 32, .38);
  transition: left 160ms var(--ease-out), right 160ms var(--ease-out);
}

/* The mouth follows the face round. */
.unit--green[data-facing="left"]  .unit__body::after { left: 14%; right: 54%; }
.unit--green[data-facing="right"] .unit__body::after { left: 54%; right: 14%; }
.unit--green[data-facing="up"]    .unit__body::after { display: none; }

/* Its back: three ports in a column, echoing the three eyes. Nothing blinks
   back here. */
.unit--green[data-facing="up"] .unit__visor {
  inset: 26% 40% 28% 40%;
  border-radius: 2px;
  animation: none;
  background:
    radial-gradient(circle closest-side, rgba(22, 56, 32, .55) 0 74%, transparent 80%) no-repeat 50% 0% / 92% 30%,
    radial-gradient(circle closest-side, rgba(22, 56, 32, .55) 0 74%, transparent 80%) no-repeat 50% 50% / 92% 30%,
    radial-gradient(circle closest-side, rgba(22, 56, 32, .55) 0 74%, transparent 80%) no-repeat 50% 100% / 92% 30%,
    rgba(255, 255, 255, .26);
  box-shadow: inset 0 0 0 1px rgba(22, 56, 32, .45);
}

/* The blue one wears ears, carries no lamp, and has a pair of eyes rather
   than a visor. The visor element becomes an invisible bar that only sets
   where the eyes sit; the eyes themselves are its pseudo-elements, sized off
   its height with a 1:1 ratio so they stay round however wide the bar is. */
.unit--blue .unit__lamp { display: none; }

.unit--blue .unit__body::before,
.unit--blue .unit__body::after {
  content: "";
  position: absolute;
  top: 24%;
  width: 15%;
  height: 30%;
  border-radius: 3px;
  background: linear-gradient(180deg, var(--bot-mid) 0%, var(--bot-deep) 100%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .4), 0 1px 0 rgba(30, 45, 60, .35);
}

.unit--blue .unit__body::before { left: -11%; }
.unit--blue .unit__body::after  { right: -11%; }

.unit--blue .unit__visor {
  inset: 30% 26% 50% 26%;
  border-radius: 0;
  background: none;
  box-shadow: none;
}

/* Each eye is a little screen: a dark bezel, glass that falls off toward the
   bottom, faint scanlines, and a glint across the top corner. All of it is
   background layers and shadows, because a pseudo-element is all there is. */
.unit--blue .unit__visor::before,
.unit--blue .unit__visor::after {
  content: "";
  position: absolute;
  top: 0;
  height: 100%;
  aspect-ratio: 1;
  border-radius: 2px;
  background:
    linear-gradient(133deg, rgba(255, 255, 255, .55) 0 34%, transparent 34%),
    repeating-linear-gradient(180deg, rgba(8, 32, 34, .24) 0 1px, transparent 1px 3px),
    linear-gradient(180deg, var(--bot-eye) 0%, var(--bot-eye-deep) 100%);
  box-shadow:
    inset 0 0 0 1px rgba(12, 30, 36, .85),
    inset 0 -2px 3px rgba(8, 40, 42, .4),
    0 0 6px color-mix(in srgb, var(--bot-eye) 66%, transparent);
}

.unit--blue .unit__visor::before { left: 0; }
.unit--blue .unit__visor::after  { right: 0; }

/* In profile only the leading eye shows; from behind, neither does. As with
   the green one, these follow the base facing rules they tie with. */
.unit--blue[data-facing="left"]  .unit__visor { inset: 30% 58% 50% 22%; }
.unit--blue[data-facing="right"] .unit__visor { inset: 30% 22% 50% 58%; }

.unit--blue[data-facing="left"]  .unit__visor::after,
.unit--blue[data-facing="right"] .unit__visor::before,
.unit--blue[data-facing="up"]    .unit__visor::before,
.unit--blue[data-facing="up"]    .unit__visor::after { display: none; }

/* Its back: a tall power pack, lit in the same screen glass as its eyes, with
   a strip that sweeps up and down the pack as it charges.

   The strip is a background layer rather than an element, so it is animated
   by its background-position. Both layers have to be listed in the keyframes;
   the pack behind it covers the whole box, so its position is inert. */
.unit--blue[data-facing="up"] .unit__visor {
  inset: 24% 32% 26% 32%;
  border-radius: 2px;
  background:
    linear-gradient(180deg, var(--bot-eye) 0%, var(--bot-eye-deep) 100%) no-repeat 50% 84% / 58% 12%,
    linear-gradient(180deg, rgba(255, 255, 255, .5) 0%, rgba(14, 40, 70, .3) 100%);
  box-shadow:
    inset 0 0 0 1px rgba(12, 30, 50, .6),
    inset 0 1px 0 rgba(255, 255, 255, .5);
  animation: charge 3.4s ease-in-out infinite;
}

@keyframes charge {
  0%, 100% { background-position: 50% 88%, 50% 0%; }
  50%      { background-position: 50% 12%, 50% 0%; }
}

.unit__lamp {
  position: absolute;
  inset: 4% 44% 84% 44%;
  border-radius: 50%;
  background: var(--bot-lamp);
  box-shadow: 0 0 8px var(--bot-lamp);
}

/* Only half the robots carry a lamp, and the two that do do not share a
   colour or a rhythm — otherwise they all read as the same character. */
.unit--green .unit__lamp { display: none; }

/* --- Standing still ------------------------------------------------------
   Each livery idles differently: steel catches the light across its visor,
   blue's screens blink together, green's three eyes blink out of step with
   one another. Everything here is slow and looping, and every rule must stay
   ABOVE the state rules below, which are meant to win the tie and take over
   while a run is in progress.
   ------------------------------------------------------------------------ */

.unit--steel .unit__lamp { animation: lamp-breathe 4.4s var(--ease-out) infinite; }
.unit--pink  .unit__lamp { animation: lamp-wink 6.2s var(--ease-out) infinite; }

@keyframes lamp-breathe {
  0%, 100% { opacity: .5; }
  50%      { opacity: 1; }
}

@keyframes lamp-wink {
  0%, 80%, 100% { opacity: 1; }
  84%           { opacity: .15; }
  88%           { opacity: 1; }
  92%           { opacity: .15; }
  96%           { opacity: 1; }
}

/* Steel: a highlight drifts across the visor now and then. The visor clips
   it, so the sweep stays on the glass. */
.unit--steel .unit__visor { overflow: hidden; }

.unit--steel .unit__visor::before {
  content: "";
  position: absolute;
  top: -20%;
  bottom: -20%;
  left: -55%;
  width: 40%;
  background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, .8) 50%, transparent 100%);
  animation: glimmer 5.4s var(--ease-out) infinite;
}

@keyframes glimmer {
  0%, 70%   { transform: translateX(0) skewX(-18deg); }
  92%, 100% { transform: translateX(420%) skewX(-18deg); }
}

/* Nothing should sweep across the vents on its back. */
.unit--steel[data-facing="up"] .unit__visor::before { display: none; }

/* Blue: both screens blink together, once, then a long hold. */
.unit--blue .unit__visor::before,
.unit--blue .unit__visor::after { animation: screen-blink 4.8s var(--ease-out) infinite; }

@keyframes screen-blink {
  0%, 91%, 97%, 100% { transform: scaleY(1); }
  94%                { transform: scaleY(.14); }
}

/* Green: the same blink on all three eyes, started at different points in
   the cycle with negative delays, so they never blink together. */
.unit--green .unit__visor          { animation: eye-blink 5.8s var(--ease-out) infinite; }
.unit--green .unit__visor::before  { animation: eye-blink 5.8s var(--ease-out) -2.1s infinite; }
.unit--green .unit__visor::after   { animation: eye-blink 5.8s var(--ease-out) -4.3s infinite; }

@keyframes eye-blink {
  0%, 88%, 100% { transform: scaleY(1); }
  92%           { transform: scaleY(.1); }
  96%           { transform: scaleY(1); }
}

/* --- On the move --------------------------------------------------------
   A walking robot stops idling. These must stay below the idle rules, which
   they match on specificity.
   ------------------------------------------------------------------------ */

/* An outcome repaints the livery rather than the body, so every part that
   takes its colour from the livery — ears included — turns with it. Declared
   after the colourways so it wins the tie on specificity. */
/* The smoke layer. Always in the markup, never visible until something sets
   it alight — the same arrangement as the shadow, and the reason the robot
   can catch fire without the renderer building anything. Three puffs: the
   element and its two pseudos, each drifting its own way on its own delay so
   they leave in a stream rather than as one cloud. */
.unit__smoke {
  position: absolute;
  left: 50%;
  top: 6%;
  width: 26%;
  aspect-ratio: 1;
  margin-left: -13%;
  border-radius: 50%;
  pointer-events: none;
  opacity: 0;
}

.unit__smoke::before,
.unit__smoke::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  opacity: 0;
}

/* Into the lava.

   Red hot first, then charred — the order matters, because a robot that goes
   straight to grey reads as switched off rather than burnt. The heat is a
   filter on the body rather than a colour, so it works on all four liveries
   without any of them naming a temperature. */
.unit--burning .unit__body {
  animation: scorch 1.2s var(--ease-out) forwards;
}

.unit--burning .unit__visor {
  animation: scorch-visor 1.2s var(--ease-out) forwards;
}

.unit--burning .unit__smoke,
.unit--burning .unit__smoke::before,
.unit--burning .unit__smoke::after {
  background: radial-gradient(circle, rgba(126, 126, 132, .8) 0 52%, rgba(126, 126, 132, 0) 72%);
  animation: puff 1.9s var(--ease-out) infinite;
}

.unit--burning .unit__smoke         { --puff-drift: -34%; animation-delay: 0ms; }
.unit--burning .unit__smoke::before { --puff-drift: 18%; animation-delay: 420ms; }
.unit--burning .unit__smoke::after  { --puff-drift: 52%; animation-delay: 840ms; }

@keyframes scorch {
  0%   { filter: none; }
  18%  { filter: brightness(1.55) saturate(3.4) sepia(.55) hue-rotate(-38deg)
                 drop-shadow(0 0 7px rgba(255, 104, 26, .95)); }
  55%  { filter: brightness(1.12) saturate(2.6) sepia(.72) hue-rotate(-32deg)
                 drop-shadow(0 0 4px rgba(255, 66, 0, .7)); }
  100% { filter: brightness(.46) saturate(.5) sepia(.5) hue-rotate(-28deg) contrast(1.15); }
}

@keyframes scorch-visor {
  0%   { opacity: 1; }
  30%  { opacity: .5; }
  100% { opacity: .12; }
}

@keyframes puff {
  0%   { opacity: 0; transform: translate(0, 0) scale(.35); }
  22%  { opacity: .6; }
  100% { opacity: 0; transform: translate(var(--puff-drift), -135%) scale(1.6); }
}

/* Walking into something solid.
 *
 * Not a failure state and not styled like one: the robot knocks, turns to
 * face what it hit, and stays exactly where it was. A wall is not a wrong
 * answer, so it costs no move and ends nothing. */
.unit--bumped .unit__body { animation: rattle 240ms var(--ease-out) 1; }

@keyframes rattle {
  0%, 100% { transform: translateX(0); }
  20%      { transform: translateX(-16%) rotate(-5deg); }
  45%      { transform: translateX(13%) rotate(4deg); }
  70%      { transform: translateX(-7%) rotate(-2deg); }
}

/* --- What the robot does -------------------------------------------------
   One state and three exits. Each is a class on `.unit`, so a view only ever
   adds or removes a class — and each animates `.unit__body`, which replaces
   the idle bob rather than fighting it, since `.unit` itself is holding the
   translate that puts the robot on its tile.
   ------------------------------------------------------------------------ */

/* Two states the robot can be in while nothing else is happening. Both put a
   bubble over its head, so the bubble itself is written once. */
.unit--asleep,
.unit--charging,
.unit--dead {
  /* So the bubble can size itself against the ROBOT rather than the page and
     stay right whatever the plot is scaled to. Scoped to these two classes so
     containment is not imposed on every robot. */
  container-type: size;
}

/* `.unit`'s own pseudo-elements were free, so a bubble costs no markup at all
   — every robot can think without carrying a bubble around for the rest of
   its life. `::after` is the bubble, `::before` is whatever is in it. */
.unit--asleep::after,
.unit--charging::after,
.unit--dead::after {
  content: "";
  position: absolute;
  bottom: 74%;
  /* Wide enough to read what is in it. Moving the left edge in as the width
     grows keeps the whole bubble inside the tile horizontally — it only ever
     overflows upward, which is what the row gap on the sheet allows for. */
  left: 44%;
  border-radius: 45% 45% 45% 8%;
  background: var(--parchment);
  box-shadow:
    inset 0 0 0 1px var(--wood),
    0 1px 3px rgba(30, 45, 20, .45);
}

/* Asleep: eyes shut, breathing slowed. Loops, because this is a state rather
   than an event — nothing has happened for a while and nothing will until you
   do something. */
.unit--asleep .unit__body { animation: doze 3.6s var(--ease-out) infinite; }

/* Eyes shut. It scales the visor, so it closes a wide visor, blue's pair of
   screens and green's three eyes alike — they are all children of it. */
.unit--asleep .unit__visor {
  transform: scaleY(.13);
  animation: none;
}

.unit--asleep .unit__visor::before,
.unit--asleep .unit__visor::after { animation: none; }

.unit--asleep .unit__lamp {
  opacity: .3;
  animation: none;
}

@keyframes doze {
  0%, 100% { transform: translateY(0) scaleY(1); }
  50%      { transform: translateY(2%) scaleY(.97); }
}

.unit--asleep::after {
  /* Lowercase. Pixelify Sans draws a capital Z as a reversed S. */
  content: "zzz";
  padding: 4cqh 9cqh;
  font-family: var(--font-pixel);
  font-size: 25cqh;
  line-height: 1.3;
  letter-spacing: .08em;
  white-space: nowrap;
  color: var(--ink);
  animation: snooze 3.6s var(--ease-out) infinite;
}

@keyframes snooze {
  0%, 100% { opacity: 0; transform: translateY(30%) scale(.7); }
  22%, 78% { opacity: 1; transform: translateY(0) scale(1); }
}

/* Charging. The flash goes on `.unit__body`, which is the one part every
   livery has in the same place — the lamp would only show on the two liveries
   that carry one, and the visor is invisible on blue, where the eyes are its
   pseudo-elements. `bob` is kept alongside rather than replaced: it drives
   `transform` and the flash drives `filter`, so the two do not collide. */
.unit--charging .unit__body {
  animation:
    bob 2.2s var(--ease-out) infinite,
    charge-flash 1.6s var(--ease-out) infinite;
}

@keyframes charge-flash {
  0%, 34%, 100% { filter: brightness(1); }
  42%           { filter: brightness(1.5) drop-shadow(0 0 6px var(--gold)); }
  50%           { filter: brightness(1.05); }
  58%           { filter: brightness(1.45) drop-shadow(0 0 5px var(--gold)); }
  68%           { filter: brightness(1); }
}

/* And on the liveries that have a lamp, it keeps time with the flash. */
.unit--charging .unit__lamp { animation: charge-lamp 1.6s var(--ease-out) infinite; }

@keyframes charge-lamp {
  0%, 34%, 100% { opacity: .3; }
  42%, 58%      { opacity: 1; }
  50%           { opacity: .4; }
}

.unit--charging::after {
  width: 48%;
  height: 40%;
  animation: spark 1.6s var(--ease-out) infinite;
}

/* The bolt in the bubble. Drawn rather than borrowed: the Heroicons set is
   the interface's language, and this is a thing in the world. */
.unit--charging::before {
  content: "";
  position: absolute;
  /* Above the bubble. `::before` is the first child and `::after` the last,
     so without this the opaque bubble paints straight over its own contents.
     `.unit` is positioned with a z-index, so it is already the stacking
     context these two sort inside. */
  z-index: 1;
  bottom: 80.5%;
  left: 58.5%;
  width: 19%;
  height: 27%;
  background: var(--gold);
  clip-path: polygon(52% 0, 96% 0, 58% 40%, 88% 40%, 20% 100%, 40% 56%, 10% 56%);
  animation: spark 1.6s var(--ease-out) infinite;
}

@keyframes spark {
  0%, 100% { opacity: .3; transform: scale(.88); }
  22%, 66% { opacity: 1; transform: scale(1); }
}

/* Flat battery. The run is over, so this one holds rather than loops: the
   robot slumps, goes grey, and stays that way until the stage restarts.

   The crossed eyes are drawn on the visor's own pseudo-elements, which is
   what makes one rule work on all four faces — blue's two screens and
   green's outer eyes ARE those elements, so overriding them replaces
   whatever face the livery had with the same pair of bars. */
.unit--dead .unit__body { animation: flatline 700ms var(--ease-out) forwards; }

@keyframes flatline {
  0%   { filter: grayscale(0) brightness(1); transform: translateY(0); }
  22%  { filter: grayscale(.35) brightness(1.3); transform: translateY(-3%); }
  100% { filter: grayscale(1) brightness(.7); transform: translateY(6%); }
}

.unit--dead .unit__visor {
  inset: 27% 20% 47% 20%;
  border-radius: 0;
  background: none;
  box-shadow: none;
  transform: none;
  animation: none;
}

.unit--dead .unit__visor::before,
.unit--dead .unit__visor::after {
  content: "";
  position: absolute;
  top: 42%;
  left: 0;
  right: 0;
  bottom: auto;
  width: auto;
  height: 16%;
  min-height: 1px;
  aspect-ratio: auto;
  border-radius: 1px;
  background: var(--ink);
  box-shadow: none;
  opacity: .85;
  animation: none;
}

.unit--dead .unit__visor::before { transform: rotate(38deg); }
.unit--dead .unit__visor::after  { transform: rotate(-38deg); }

.unit--dead .unit__lamp {
  opacity: 0;
  animation: none;
}

.unit--dead::after {
  width: 48%;
  height: 40%;
  animation: pop 400ms var(--ease-hop) forwards;
}

/* An empty cell: an OUTLINE with a terminal, not a filled block.

   The first attempt was a red body with a small hole punched in it, which at
   fifteen pixels read as a red blob rather than a battery — the shell was
   thicker than the space inside it, so the eye saw the fill and not the
   shape. Now the hollow is most of the cell and the shell is a two-pixel
   border around it, which is what makes a battery read as a battery. The
   terminal butts against the shell rather than floating beside it.

   Three flat layers, back to front: shell, hollow, terminal. Sized so the
   hollow sits centred in the shell rather than in the element, since the
   terminal takes up the last eighth. */
.unit--dead::before {
  content: "";
  position: absolute;
  z-index: 1;
  bottom: 84%;
  left: 52%;
  width: 32%;
  height: 20%;
  background:
    linear-gradient(var(--berry), var(--berry)) no-repeat 100% 50% / 12% 44%,
    linear-gradient(var(--parchment), var(--parchment)) no-repeat 34% 50% / 62% 62%,
    linear-gradient(var(--berry), var(--berry)) no-repeat 0 50% / 88% 100%;
  animation: pop 400ms var(--ease-hop) forwards;
}

@keyframes pop {
  0%   { opacity: 0; transform: translateY(30%) scale(.7); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}

/* Falls in. A beat of hanging in the air first, because the drop reads as a
   drop only if something precedes it. */
.unit--falling .unit__body { animation: tumble 950ms var(--ease-out) forwards; }

@keyframes tumble {
  0%   { transform: translateY(0) scale(1) rotate(0deg); }
  18%  { transform: translateY(-9%) scale(1.03) rotate(-7deg); }
  100% { transform: translateY(52%) scale(.12) rotate(32deg); opacity: 0; }
}

/* Into the portal: pulled in and spun down to nothing, brightening as it
   goes. */
.unit--warping .unit__body { animation: warp 950ms var(--ease-hop) forwards; }

@keyframes warp {
  0%   { transform: scale(1) rotate(0deg); filter: brightness(1); }
  32%  { transform: scale(1.07) rotate(140deg); filter: brightness(1.35); }
  100% { transform: scale(0) rotate(760deg); filter: brightness(2.4); opacity: 0; }
}

/* Into the teleport: brightened and stretched away on the spot.

   It stays CENTRED — no drift upward. A robot that slides off its tile as it
   goes reads as being pulled somewhere in the world, and a teleport is the
   one exit that is not: the tile it is standing on is the whole event. The
   stretch is symmetrical about the centre, so the last frame is still dead
   on the tile.

   No arcs either. Lightning either side of a robot reads as electrocution,
   which is a thing happening TO it rather than a thing it is doing. The
   brightness alone carries it, and it is the opposite shape to the portal --
   one spins the robot down to nothing, the other burns it out. */
.unit--teleporting .unit__body { animation: teleport 850ms var(--ease-out) forwards; }

@keyframes teleport {
  0%   { transform: scale(1, 1); filter: brightness(1); }
  22%  { transform: scale(1.06, .94); filter: brightness(1.6); }
  44%  { transform: scale(.96, 1.08); filter: brightness(2.1); }
  72%  { transform: scale(.86, 1.16); filter: brightness(2.7); opacity: .6; }
  100% { transform: scale(.6, 1.24); filter: brightness(3.4); opacity: 0; }
}

/* Whatever the robot is leaving by, its shadow goes with it. */
.unit--falling .unit__shadow,
.unit--warping .unit__shadow,
.unit--teleporting .unit__shadow { animation: unshadow 900ms var(--ease-out) forwards; }

@keyframes unshadow {
  0%   { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(.15); }
}

/* --- Banner -------------------------------------------------------------- */

.board__banner {
  position: absolute;
  left: 50%;
  bottom: clamp(var(--gap-4), 5%, var(--gap-6));
  z-index: 3;
  display: block;
  padding: var(--gap-2) var(--gap-5);
  border-radius: var(--radius);
  font-family: var(--font-pixel);
  font-size: 1rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--ink);
  background: linear-gradient(180deg, var(--parchment), var(--parchment-sh));
  box-shadow: inset 0 0 0 3px var(--wood), 0 3px 0 var(--wood-deep), 0 8px 18px rgba(30, 45, 20, .45);
  opacity: 0;
  transform: translate(-50%, 14px);
  transition: opacity 240ms var(--ease-out), transform 240ms var(--ease-hop);
}

.board__banner[data-show="true"] {
  opacity: 1;
  transform: translate(-50%, 0);
}

.board__banner[data-tone="won"] {
  color: var(--wood-deep);
  box-shadow: inset 0 0 0 3px var(--gold), 0 3px 0 var(--gold-deep), 0 8px 18px rgba(30, 45, 20, .45);
}

.board__banner[data-tone="crashed"],
.board__banner[data-tone="lost"] {
  color: var(--berry-deep);
  box-shadow: inset 0 0 0 3px var(--berry), 0 3px 0 var(--berry-deep), 0 8px 18px rgba(30, 45, 20, .45);
}

/* --- The dead end ---------------------------------------------------------
   Shown when the robot is lost. There is no way past a hazard, so this has one
   control and no way to dismiss it: no backdrop click, no close button. That
   is deliberate rather than an oversight — being made to press something is
   what makes the mistake register.

   It also means the usual modal guard does not apply here. Nothing closes on
   a backdrop press, so a drag that starts on the card and ends outside it has
   nothing to trigger.
   ------------------------------------------------------------------------ */

.modal {
  position: fixed;
  inset: 0;
  z-index: 20;
  display: grid;
  place-items: center;
  padding: var(--gap-5);
  background: rgba(18, 14, 10, .58);
  animation: modal-in 160ms var(--ease-out);
}

.modal[hidden] { display: none; }

.modal__card {
  display: grid;
  justify-items: center;
  gap: var(--gap-3);
  width: min(100%, 22rem);
  padding: var(--gap-5);
  text-align: center;
  border-radius: var(--radius);
  background: var(--page);
  box-shadow:
    inset 0 0 0 3px var(--berry),
    0 12px 34px rgba(20, 14, 8, .45);
  animation: modal-rise 200ms var(--ease-hop);
}

.modal__title {
  margin: 0;
  font-family: var(--font-pixel);
  font-size: 1.35rem;
  letter-spacing: .04em;
  color: var(--berry-deep);
}

.modal__note {
  margin: 0;
  font-size: .92rem;
  line-height: 1.5;
  color: var(--ink);
}

@keyframes modal-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes modal-rise {
  from { opacity: 0; transform: translateY(-16px) scale(.97); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}


/* ==========================================================================
   THE NEW FLOORS AND FIXTURES
   Ice, costly ground, cracked ground, and the toggle. Every one of these is
   a property of the tile rather than a thing standing on it, which is why
   they are attributes on `.tile` and not extra props.
   ========================================================================== */

/* --- Ice -----------------------------------------------------------------
   The robot does not stop on it, so it has to READ as slippery before the
   player finds that out the hard way: a cold sheen, and a grain running one
   way like a wind-scoured surface. Anchored to --col/--row like the lava, so
   a field is one sheet rather than a tray of tiles. */
.tile[data-floor="ice"] {
  background-image:
    linear-gradient(115deg,
      rgb(255 255 255 / .34) 0 12%,
      rgb(255 255 255 / 0) 12% 26%,
      rgb(255 255 255 / .22) 26% 33%,
      rgb(255 255 255 / 0) 33% 100%),
    linear-gradient(180deg, var(--ice-sheen) 0%, var(--ice-sheet) 100%);
  background-size: calc(var(--tile-size) * 3) calc(var(--tile-size) * 3), 100% 100%;
  background-position:
    calc(var(--col) * var(--tile-size) * -1) calc(var(--row) * var(--tile-size) * -1),
    0 0;
  box-shadow: inset 0 1px 0 rgb(255 255 255 / .5);
}

/* --- Costly ground -------------------------------------------------------
   Two blocks instead of one, so it must look like hard going: broken, dark,
   and busy enough that the eye reads it as slow. */
.tile[data-floor="costly"] {
  background-image:
    radial-gradient(circle at 22% 30%, var(--costly-grit) 0 14%, transparent 15%),
    radial-gradient(circle at 68% 22%, var(--costly-grit) 0 11%, transparent 12%),
    radial-gradient(circle at 40% 72%, var(--costly-grit) 0 16%, transparent 17%),
    radial-gradient(circle at 82% 66%, var(--costly-grit) 0 10%, transparent 11%),
    linear-gradient(180deg, var(--costly-mud) 0%, var(--costly-deep) 100%);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / .12);
}

/* --- Cracked ground ------------------------------------------------------
   It holds exactly once, so it is drawn cracked from the start. TELEGRAPHED
   is the whole point: with no undo, ground that gives way without warning is
   not difficulty, it is a con. */
.tile[data-crack] { position: relative; }

.tile[data-crack]::after {
  content: "";
  position: absolute;
  inset: 8%;
  pointer-events: none;
  background-image:
    linear-gradient(72deg, transparent 46%, var(--crack-line) 46% 52%, transparent 52%),
    linear-gradient(155deg, transparent 44%, var(--crack-line) 44% 49%, transparent 49%),
    linear-gradient(18deg, transparent 47%, var(--crack-line) 47% 51%, transparent 51%);
  opacity: .85;
  transition: opacity var(--tick) linear;
}

/* Gone. The view sets `data-hazard="hole"` on it, so it becomes a REAL hole
   and inherits the chasm's own drawing -- the walls, the mitred corners, all
   of it. Same hazard, same death, same art: there is no second kind of pit,
   and there is no second implementation of one either. All that is left here
   is the moment it gives way. */
.tile[data-fallen="true"] { animation: give-way var(--tick) var(--ease-out) both; }

.tile[data-fallen="true"]::after { opacity: 0; }

@keyframes give-way {
  from { transform: scale(1); filter: brightness(1); }
  60%  { transform: scale(.94); filter: brightness(.5); }
  to   { transform: scale(1); filter: brightness(1); }
}

/* --- The toggle ----------------------------------------------------------
   Two classes of wall, and one attribute on the ground layer decides which of
   them is solid. Every wall on the plot changes in a single CSS state change,
   which is the drama the mechanic is for.

   Both states are drawn: solid is a wall, open is a visible FRAME of the same
   colour, so the player can always see where the other half of the board is
   about to appear. */
.tile[data-toggle] {
  --toggle: var(--toggle-a);
  --toggle-deep: var(--toggle-a-deep);
  transition: background-color var(--tick) linear, box-shadow var(--tick) linear;
}

.tile[data-toggle="B"] { --toggle: var(--toggle-b); --toggle-deep: var(--toggle-b-deep); }

/* Solid: class A while the plot is in phase 0, class B while it is in 1. */
.playfield__ground:not([data-phase="1"]) .tile[data-toggle="A"],
.playfield__ground[data-phase="1"] .tile[data-toggle="B"] {
  background: linear-gradient(180deg, var(--toggle) 0%, var(--toggle-deep) 100%);
  box-shadow:
    inset 0 2px 0 rgb(255 255 255 / .35),
    inset 0 -3px 0 rgb(0 0 0 / .3);
}

.playfield__ground:not([data-phase="1"]) .tile[data-toggle="A"] .tile__prop,
.playfield__ground[data-phase="1"] .tile[data-toggle="B"] .tile__prop {
  opacity: 0;
}

/* Open: a frame where the wall will come back. */
.playfield__ground[data-phase="1"] .tile[data-toggle="A"],
.playfield__ground:not([data-phase="1"]) .tile[data-toggle="B"] {
  background: transparent;
  box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--toggle) 55%, transparent);
}

.playfield__ground[data-phase="1"] .tile[data-toggle="A"] .tile__prop,
.playfield__ground:not([data-phase="1"]) .tile[data-toggle="B"] .tile__prop {
  opacity: 0;
}

/* --- The plate -----------------------------------------------------------
   What swaps them. Drawn in both toggle colours at once, because that is
   exactly what it does. */
.tile--plate .tile__prop {
  position: absolute;
  inset: 16%;
  border-radius: 3px;
  background:
    linear-gradient(135deg, var(--toggle-a) 0 50%, var(--toggle-b) 50% 100%);
  box-shadow:
    inset 0 2px 0 rgb(255 255 255 / .4),
    0 2px 0 rgb(0 0 0 / .3);
}
