cartridge.cafe · open source world

QUANTIC DOJO

HOW TO PLAY

QUANTIC DOJO — The Evolution of Tic-Tac-Toe. By Galen Radtke. XIN (X, magenta) versus OHN (O, green). The DOJO MIND plays Ohn — click the green lamp by Ohn's pieces to switch to two-player hotseat. CLICK a piece on your side · R or your ring rotates it · CLICK an orange/white square to place it Each face is a little tic-tac-toe: your mark stands where the piece stands; the colored flag is its ATTACK ZONE, pointing where victims get pushed. Ranged pieces show mark and zone on opposite edges — the empty span between says it strikes past the first square. Pushes chain through rows. Off the board = back to hand. Three hits in one turn breaks a piece forever. Chain attacks: when a flashing piece of yours can strike, click it. THREE-IN-A-ROW at the end of any turn WINS. Both at once is a draw. Can't place? You lose. The bar below resigns with honor.

built by: Claude Fable 5 — with Galen Radtke, from his 2014 manual
1 visual shader · 0 shader modules · 1 step hook · runs on WebGPU in the browser
This source is part of the cartridge.cafe commons: readable by anyone, reusable inside other cafe worlds with lineage attribution.

— VISUAL SHADERS (WGSL) —

visual · qd

// ═══ QUANTIC DOJO — the diamond, the pieces, the petals ═══
// Original procedural art after Galen Radtke's 2014 board and card system:
// blue field, orange middle eight, white eye; each piece face is a small
// tic-tac-toe grid — mark where the piece stands, flag where it strikes.

fn qd_rot2(p: vec2f, k: f32) -> vec2f {
  let a = -k * 1.5707963;
  let c = cos(a); let s = sin(a);
  return vec2f(p.x * c - p.y * s, p.x * s + p.y * c);
}
fn qd_rbox(p: vec2f, b: vec2f, r: f32) -> f32 {
  let q = abs(p) - b + vec2f(r);
  return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
}
fn qd_seg(p: vec2f, a: vec2f, b: vec2f) -> f32 {
  let pa = p - a; let ba = b - a;
  let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return length(pa - ba * h);
}
fn qd_tri(q: vec2f, s: f32) -> f32 {
  // filled flag pointing +up in local space
  return max(abs(q.x) * 1.5 - (0.62 * s - q.y * 0.9), -q.y - s * 0.72);
}
fn qd_inMask(r: f32, c: f32) -> bool {
  return abs(r - 4.0) + abs(c - 4.0) <= 4.01 && r > -0.5 && r < 8.5 && c > -0.5 && c < 8.5;
}
fn qd_c9(r: f32, c: f32) -> bool { return abs(r - 4.0) <= 1.01 && abs(c - 4.0) <= 1.01; }

// per-kind card layout: symbol cell, zone cell, push dir, style
// 0 Spear 1 Sai 2 Ninjato 3 Nunchaku 4 Shurikin 5 Arrows 6 Exotic 7 Kusari
// 8 Ramen 9 Bomb 10 Potion 11 Kunai · style: 1 ranged-ring 2 kunai 3 potion 4 bomb 5 ramen
const QD_SYM = array<vec2f, 12>(
  vec2f(0.0, 0.0), vec2f(0.0, 0.0), vec2f(0.0, 0.0), vec2f(0.0, 0.0),
  vec2f(0.0, 1.0), vec2f(-1.0, 1.0), vec2f(0.0, 0.0), vec2f(0.0, 0.0),
  vec2f(0.0, 0.0), vec2f(0.0, 0.0), vec2f(0.0, 0.0), vec2f(0.0, 0.0));
const QD_ZON = array<vec2f, 12>(
  vec2f(0.0, -1.0), vec2f(1.0, -1.0), vec2f(0.0, -1.0), vec2f(0.0, -1.0),
  vec2f(0.0, -1.0), vec2f(1.0, -1.0), vec2f(0.0, -1.0), vec2f(0.0, -1.0),
  vec2f(0.0, -1.0), vec2f(0.0, -1.0), vec2f(-1.0, -1.0), vec2f(1.0, -1.0));
const QD_PDR = array<vec2f, 12>(
  vec2f(0.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 0.0), vec2f(1.0, 0.0),
  vec2f(0.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0), vec2f(1.0, 1.0),
  vec2f(0.0, 1.0), vec2f(0.0, 1.0), vec2f(0.0, 0.0), vec2f(0.0, -1.0));
const QD_STY = array<f32, 12>(0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 5.0, 4.0, 3.0, 2.0);

fn qd_face(lp: vec2f, kind: i32, own: f32, t: f32) -> vec4f {
  // lp in [-1,1] face space (already rotated). returns rgb + coverage
  let INKX = vec3f(0.92, 0.16, 0.44);
  let INKO = vec3f(0.55, 0.78, 0.10);
  let ink = select(INKX, INKO, own > 0.5);
  let dark = vec3f(0.12, 0.10, 0.12);
  var col = vec3f(0.0); var a = 0.0;
  // the little tic-tac-toe grid
  let gl = min(min(abs(abs(lp.x) - 0.333), abs(abs(lp.y) - 0.333)), 1.0);
  let gm = smoothstep(0.028, 0.012, gl) * step(max(abs(lp.x), abs(lp.y)), 0.99);
  col = mix(col, vec3f(0.6, 0.6, 0.62), gm * 0.38); a = max(a, gm * 0.38);
  // the card's bold border
  let bd = smoothstep(0.07, 0.02, abs(max(abs(lp.x), abs(lp.y)) - 0.94));
  col = mix(col, vec3f(0.16, 0.14, 0.15), bd); a = max(a, bd);
  // symbol: X or O at its cell
  let sc = QD_SYM[kind] * 0.667;
  let sq = (lp - sc) / 0.34;
  var sm = 0.0;
  if (own < 0.5) {
    sm = smoothstep(0.20, 0.08, min(qd_seg(sq, vec2f(-0.5, -0.5), vec2f(0.5, 0.5)), qd_seg(sq, vec2f(-0.5, 0.5), vec2f(0.5, -0.5))));
  } else {
    sm = smoothstep(0.17, 0.06, abs(length(sq) - 0.44));
  }
  col = mix(col, dark, sm); a = max(a, sm);
  // zone flag: triangle in the zone cell, nose along the push direction
  let zc = QD_ZON[kind] * 0.667;
  let pd = QD_PDR[kind];
  let sty = QD_STY[kind];
  if (sty < 1.5 || sty > 3.5) {   // plain, ranged, bomb, ramen wear the flag
    var fq = (lp - zc) / 0.34;
    if (abs(pd.x) + abs(pd.y) > 0.1) {
      // rotate by the direction VECTOR itself — atan2(±1, 0) is an edge case
      // GPUs disagree on, which is why only pure east/west arrows lied
      let u = normalize(pd);
      fq = vec2f(-u.y * fq.x + u.x * fq.y, -u.x * fq.x - u.y * fq.y);
    }
    // an UNMISTAKABLE arrow: thin shaft, fat head at the nose. The old flag's
    // heavy base + tail read as the pointer at tile size, so correct arrows
    // looked reversed — the geometry was innocent, the glyph was guilty.
    let shaft = qd_seg(fq, vec2f(0.0, 0.62), vec2f(0.0, 0.0));
    let head = max(qd_tri(vec2f(fq.x / 0.9, (-fq.y - 0.08) / 0.9), 0.8), fq.y - 0.02);
    var fm = smoothstep(0.11, 0.045, shaft);
    fm = max(fm, smoothstep(0.05, -0.02, head));
    col = mix(col, ink, fm); a = max(a, fm);
    if (sty > 3.5 && sty < 4.5) {   // bomb: the black round under the flag
      let bm = smoothstep(0.06, 0.0, length(lp - vec2f(0.0, 0.52)) - 0.18);
      col = mix(col, dark, bm); a = max(a, bm);
    }
  } else if (sty > 2.5) {   // potion
    // potion: the swirl in its corner
    let pq = (lp - zc) / 0.30;
    let r0 = length(pq);
    let th = atan2(pq.y, pq.x);
    let sp = abs(r0 - (0.26 + 0.14 * (th / 3.14159 + 1.0)));
    var pm = smoothstep(0.16, 0.05, sp);
    pm = max(pm, smoothstep(0.14, 0.04, r0));
    col = mix(col, ink, pm); a = max(a, pm);
  } else {
    // kunai: two small arrows diverging — two futures
    for (var i = 0; i < 2; i++) {
      let d = select(vec2f(-0.7, -0.7), vec2f(0.7, 0.7), i > 0);   // deflected to either side of the blade
      var fq = (lp - zc) / 0.34;
      let u2 = normalize(d);
      fq = vec2f(-u2.y * fq.x + u2.x * fq.y, -u2.x * fq.x - u2.y * fq.y);
      let shaft2 = qd_seg(fq, vec2f(0.0, 0.55), vec2f(0.0, 0.05));
      let head2 = max(qd_tri(vec2f(fq.x / 0.62, (-fq.y - 0.02) / 0.62), 0.7), fq.y - 0.06);
      var km = smoothstep(0.10, 0.04, shaft2);
      km = max(km, smoothstep(0.05, -0.02, head2));
      col = mix(col, ink, km); a = max(a, km);
    }
  }
  return vec4f(col, a);
}

fn visual_qd(uv: vec2f, sdf: f32, color: vec4f, time: f32, params: vec4f, behind: vec4f) -> vec4f {
  let p = (uv + vec2f(1.0)) * 256.0;
  let t = uni(7);
  let DK = 40.0;
  let XIN = vec3f(0.92, 0.16, 0.44);
  let OHN = vec3f(0.55, 0.78, 0.10);
  let GOLD = vec3f(1.0, 0.82, 0.38);

  // the dojo at dusk — the first attempt's water carries the manual's board
  var col = mix(vec3f(0.045, 0.045, 0.10), vec3f(0.10, 0.16, 0.20), clamp(p.y / 512.0, 0.0, 1.0));
  col += vec3f(0.10, 0.05, 0.10) * exp(-abs(p.y - 120.0) * 0.012) * 0.8;
  let wav = sin(p.x * 0.02 + t * 0.4 + sin(p.y * 0.015)) * sin(p.y * 0.017 - t * 0.22);
  col += vec3f(0.02, 0.035, 0.045) * (0.5 + 0.5 * wav);
  for (var i = 0; i < 10; i++) {
    let fi = f32(i);
    let h1 = hash11(fi * 17.3); let h2 = hash11(fi * 31.7);
    let py = fract(t * (0.018 + h1 * 0.02) + h2) * 560.0 - 20.0;
    let pxx = h1 * 512.0 + sin(t * (0.5 + h2) + fi) * 22.0;
    var pq = qd_rot2((p - vec2f(pxx, py)) / 7.0, fract(t * 0.13 + h2) * 4.0);
    pq.x *= 1.8;
    let pm = smoothstep(0.9, 0.6, length(pq));
    col = mix(col, vec3f(0.95, 0.35, 0.55) * (0.5 + 0.5 * h2), pm * 0.5);
  }

  // the board — straight, a diamond of squares like the manual's page
  let cr = round((p.y - 252.0) / DK + 4.0); let cc = round((p.x - 256.0) / DK + 4.0);
  if (qd_inMask(cr, cc)) {
    let ctr = vec2f(256.0 + (cc - 4.0) * DK, 252.0 + (cr - 4.0) * DK);
    let d = p - ctr;
    let dia = qd_rbox(d, vec2f(DK * 0.46), 4.0);
    var cellc = vec3f(0.30, 0.52, 0.66);                       // dusk-water blue
    if (qd_c9(cr, cc)) { cellc = vec3f(1.0, 0.66, 0.16); }     // the orange eight
    if (cr == 4.0 && cc == 4.0) { cellc = vec3f(0.96, 0.95, 0.92); }  // the white eye
    cellc *= 0.92 + 0.08 * sin(cr * 2.1 + cc * 3.3);
    // placing: the receiving nine breathe
    if (uni(1) < 0.5 && uni(23) >= 0.0 && qd_c9(cr, cc) && !(uni(9) < 0.5 && cr == 4.0 && cc == 4.0)) {
      cellc += vec3f(0.10, 0.08, 0.02) * (0.6 + 0.4 * sin(t * 2.4));
    }
    col = mix(col, cellc, smoothstep(1.2, -1.2, dia));
    if (cr == 4.0 && cc == 4.0) {
      col = mix(col, vec3f(0.2, 0.2, 0.22), smoothstep(1.5, 0.0, abs(length(d) - DK * 0.34)));
    }
  }
  // win halo
  for (var i = 0; i < 3; i++) {
    let wr = uni(12 + i * 2); let wc = uni(13 + i * 2);
    if (wr > -5.0) {
      let wp = vec2f(256.0 + (wc - 4.0) * DK, 252.0 + (wr - 4.0) * DK);
      col += GOLD * 0.4 * exp(-length(p - wp) * 0.05) * (0.7 + 0.3 * sin(t * 3.0));
    }
  }
  // kunai choice markers
  for (var i = 0; i < 2; i++) {
    let dr = uni(18 + i * 2); let dc = uni(19 + i * 2);
    if (dr > -5.0 && qd_inMask(dr, dc)) {
      let dp = vec2f(256.0 + (dc - 4.0) * DK, 252.0 + (dr - 4.0) * DK);
      col += GOLD * 0.8 * smoothstep(2.5, 0.0, abs(length(p - dp) - 12.0 - 2.0 * sin(t * 4.0)));
    }
  }

  // pieces
  for (var i = 0; i < 24; i++) {
    let B = 30 + i * 7;
    let st = uni(B + 5);
    if (st < 0.25) { continue; }
    let tp = vec2f(uni(B), uni(B + 1));
    let scl = select(14.0, 18.0, st > 0.75);
    let lp0 = (p - tp) / scl;
    if (abs(lp0.x) > 1.5 || abs(lp0.y) > 1.5) { continue; }
    let face = qd_rbox(lp0 * scl, vec2f(scl * 0.96), 3.5);
    let m = smoothstep(1.0, -0.8, face);
    col = mix(col, vec3f(0.02, 0.02, 0.03), smoothstep(5.0, 0.0, face + 3.5) * 0.4 * (1.0 - m));
    col = mix(col, vec3f(0.93, 0.91, 0.86), m);
    let lp = qd_rot2(lp0, uni(B + 2));
    let g = qd_face(lp, i32(uni(B + 3)), uni(B + 4), t);
    col = mix(col, g.rgb, g.a * m);
    let pulse = uni(B + 6);
    if (pulse > 0.5) {
      let rim = smoothstep(2.6, 0.4, abs(face - 2.6));
      col += select(GOLD, vec3f(1.0, 0.95, 0.8), pulse > 1.5) * rim * (0.5 + 0.5 * sin(t * 5.0));
    }
  }
  // ghost
  let gR = uni(3); let gC = uni(4);
  if (gR > -5.0) {
    let gp = vec2f(256.0 + (gC - 4.0) * DK, 252.0 + (gR - 4.0) * DK);
    let lp0 = (p - gp) / 18.0;
    if (abs(lp0.x) < 1.5 && abs(lp0.y) < 1.5) {
      let face = qd_rbox(lp0 * 18.0, vec2f(17.3), 3.5);
      let m = smoothstep(1.0, -0.8, face) * select(0.45, 0.8, uni(26) > 0.5);
      col = mix(col, vec3f(0.93, 0.91, 0.86), m);
      let g = qd_face(qd_rot2(lp0, uni(5)), i32(uni(23)), uni(0), t);
      col = mix(col, g.rgb, g.a * m);
    }
  }

  // the staged piece's attack zone, ringed in the mover's ink
  let zr = uni(24); let zc = uni(25);
  if (zr > -5.0) {
    let zp = vec2f(256.0 + (zc - 4.0) * DK, 252.0 + (zr - 4.0) * DK);
    let ink2 = select(XIN, OHN, uni(0) > 0.5);
    col += ink2 * 0.8 * smoothstep(2.2, 0.4, abs(length(p - zp) - 13.0 - 1.5 * sin(t * 3.0)));
  }

  // the MIND lamp — an eye that is lit when the machine sits at Ohn's side
  let lampP = p - vec2f(472.0, 40.0);
  let lit = uni(8) > 0.5;
  col += select(vec3f(0.06), OHN * 0.6, lit) * exp(-dot(lampP, lampP) * 0.02) * select(0.5, 1.2, lit && uni(0) > 0.5);
  col = mix(col, vec3f(0.9), smoothstep(1.2, 0.0, abs(length(lampP) - 9.0)));
  col = mix(col, select(vec3f(0.2), OHN, lit), smoothstep(0.5, 0.0, length(lampP) - 3.5));

  // rotate rings by each hand
  {
    let bp = p - vec2f(40.0, 466.0);
    let act = uni(1) < 0.5;
    col += select(vec3f(0.08), GOLD * 0.5, act) * smoothstep(2.0, 0.0, abs(length(bp) - 13.0)) * select(0.3, 0.9, act);
    col += vec3f(0.9, 0.85, 0.6) * smoothstep(2.0, 0.5, qd_seg(bp, vec2f(-5.0, -3.0), vec2f(5.0, -3.0))) * 0.35;
  }
  // (resign bar retired — the UI SYSTEM's labeled CONCEDE button owns that seat)

  // the seat menu: three quiet bars over the dimmed table
  if (uni(27) < -0.5) {
    col *= 0.45;
    for (var i = 0; i < 3; i++) {
      let bb = qd_rbox(p - vec2f(256.0, 190.0 + f32(i) * 62.0), vec2f(112.0, 20.0), 10.0);
      col = mix(col, vec3f(0.07, 0.06, 0.09), smoothstep(0.0, -1.5, bb) * 0.9);
      col += vec3f(0.85, 0.75, 0.5) * smoothstep(1.8, 0.2, abs(bb)) * (0.5 + 0.2 * sin(t * 2.0 + f32(i)));
    }
  }

  // victory wash
  if (uni(1) > 3.5) {
    let wc2 = select(select(XIN, OHN, uni(6) > 0.5), GOLD, uni(6) > 1.5);
    col += wc2 * 0.10 * (0.6 + 0.4 * sin(t * 1.6)) * exp(-abs(p.y - 252.0) * 0.004);
  }
  return vec4f(col, 1.0);
}

— STEP HOOKS (JAVASCRIPT) —

hook · qd_rules

QUANTIC DOJO — The Evolution of Tic-Tac-Toe (© 2014 Galen Radtke, adapted with the designer). Xin vs Ohn on the diamond: place a weapon in the central nine, it attacks, pushes chain, three-in-a-row wins. All 12 pieces (Spear→Potion), Bomb no-landing rule, three-hits removal, ties, resign, and the DOJO MIND — a built-in opponent playing Ohn.
try {
  const wd = sim.worldData
  // ═══ QUANTIC DOJO — The Evolution of Tic-Tac-Toe · © 2014 Galen Radtke ═══
  // Xin (X, magenta) and Ohn (O, green) duel on the diamond. Place a piece in
  // the central nine, it attacks; pushes chain; three-in-a-row wins the bout.
  const K = [
    { n: 'Spear',          t: [-1, 0],  m: [-1, 0] },
    { n: 'Sai',            t: [-1, 1],  m: [-1, 1] },
    { n: 'Ninjato',        t: [-1, 0],  m: [0, -1] },
    { n: 'Nunchaku',       t: [-1, 0],  m: [0, 1] },
    { n: 'Giant Shurikin', t: [-2, 0],  m: [-1, 0], rng: 1 },
    { n: 'Arrows',         t: [-2, 2],  m: [-1, 1], rng: 1 },
    { n: 'Exotic Blade',   t: [-1, 0],  m: [1, -1] },
    { n: 'Kusarigama',     t: [-1, 0],  m: [1, 1] },
    { n: 'Ramen',          t: [-1, 0],  m: [1, 0], both: 1 },
    { n: 'Bomb',           t: [-1, 0],  m: [1, 0], bomb: 1 },
    { n: 'Potion',         t: [-1, -1], rot: 1, any: 1 },
    { n: 'Kunai',          t: [-1, 1],  dec: 1 },
  ]
  // what each weapon DOES, in plain words — the UI panel teaches on select/hover
  const KD = [
    'strikes ahead — pushes the row onward',
    'strikes the right diagonal — pushes along it',
    'strikes ahead — sweeps the row to its left',
    'strikes ahead — sweeps the row to its right',
    'strikes TWO ahead — pushes the row onward',
    'strikes two out on the diagonal — pushes on',
    'strikes ahead — drags them back-left',
    'strikes ahead — drags them back-right',
    'strikes ahead — pulls them in, row falls back',
    'strikes ahead — tosses them behind the bomb',
    'catches the back diagonal — SPINS any piece',
    'strikes the diagonal — deflects: your choice',
  ]
  const inMask = (r, c) => Math.abs(r - 4) + Math.abs(c - 4) <= 4
  const central9 = (r, c) => Math.abs(r - 4) <= 1 && Math.abs(c - 4) <= 1
  const CELL = 40
  const px = (r, c) => [256 + (c - 4) * CELL, 252 + (r - 4) * CELL]
  const rotV = (v, k) => { let o = [v[0], v[1]]; for (let i = 0; i < ((k % 4) + 4) % 4; i++) o = [o[1], -o[0]]; return o }

  if (!wd.__qd || wd.__qd.v !== 5) {
    const tiles = []
    for (let p = 0; p < 2; p++) for (let k = 0; k < 12; k++)
      tiles.push({ id: p * 12 + k, p, k, rot: 0, r: -1, c: -1, on: 0, acted: -1, hits: 0, ax: 0, ay: 0, at: -9 })
    wd.__qd = { v: 5, tiles, turn: 0, tn: 1, phase: 0, sel: -1, gr: 0, first: 0, ai: 1, aiT: 0, hseat: -1, preSel: -1,
                win: -1, why: '', wl: [], pend: null, elig: [], moved: [], keys: {}, mh: 0 }
    for (const t of tiles) {
      // hands at each side: Xin left, Ohn right — two columns of six
      const col = Math.floor(t.k / 6), row = t.k % 6
      t.dx = (t.p === 0 ? 20 : 457) + col * 35
      t.dy = 128 + row * 50
      t.ax = t.dx; t.ay = t.dy
    }
  }
  const S = wd.__qd
  const T = S.tiles
  const at = (r, c) => T.find(t => t.on === 1 && t.r === r && t.c === c)
  const now = (S.clk = (S.clk || 0) + Math.min(dt || 0.016, 0.1))
  const mx = wd.mouse_x, my = wd.mouse_y, md = wd.mouse_down
  const hasM = typeof mx === 'number'
  const click = md && !S.mh && hasM
  const keyEdge = k => { const n = wd['key_' + k + '_n'] || 0; const was = S.keys[k] || 0; S.keys[k] = n; return n > was }
  const tone = (f, d, v, ty) => { wd.__play_sound = { frequency: f, duration: d || 0.12, volume: v || 0.2, type: ty || 'sine' } }
  const tgtCell = t => { const o = rotV(K[t.k].t, t.rot); return [t.r + o[0], t.c + o[1]] }
  const mark = t => { const [a, b] = t.on === 1 ? px(t.r, t.c) : [t.dx, t.dy]; t.ax = a; t.ay = b; t.at = now }
  const hit = t => { t.hits++; }
  const lineMove = (r, c, dir) => {
    const seq = []
    let rr = r, cc = c
    while (inMask(rr, cc) && at(rr, cc)) { seq.push(at(rr, cc)); rr += dir[0]; cc += dir[1] }
    const moved = []
    for (let i = seq.length - 1; i >= 0; i--) {
      const t = seq[i], nr = t.r + dir[0], nc = t.c + dir[1]
      mark(t); hit(t)
      if (!inMask(nr, nc)) { t.on = 0; t.rot = 0; t.acted = -1; tone(160, 0.25, 0.28, 'triangle') }
      else { t.r = nr; t.c = nc }
      moved.push(t.id)
    }
    return moved
  }
  const resolveImpact = (tl, vt, choice) => {
    const KK = K[tl.k]
    let moved = []
    S.spun = []
    if (KK.rot) { mark(vt); vt.rot = choice; hit(vt); moved = [vt.id]; S.spun = [vt.id]; if (vt.p === tl.p) vt.acted = -1 }
    else if (KK.dec) { moved = lineMove(vt.r, vt.c, rotV(choice === 0 ? [-1, -1] : [1, 1], tl.rot)) }
    else if (KK.both) {
      const back = rotV([1, 0], tl.rot), or = tl.r, oc = tl.c
      moved = lineMove(tl.r, tl.c, back)
      mark(vt); hit(vt); vt.r = or; vt.c = oc; moved.push(vt.id)
    } else if (KK.bomb) {
      // the tossed piece lands behind the Bomb; an occupied landing ROW gets
      // pushed out of the way first (the manual's own 'see Row Attacks' note)
      const back = rotV([1, 0], tl.rot), dr = tl.r + back[0], dc = tl.c + back[1]
      if (!inMask(dr, dc)) { mark(vt); hit(vt); vt.on = 0; vt.rot = 0; vt.acted = -1; moved = [vt.id] }
      else {
        moved = at(dr, dc) ? lineMove(dr, dc, back) : []
        mark(vt); hit(vt); vt.r = dr; vt.c = dc; moved.push(vt.id)
      }
    } else moved = lineMove(vt.r, vt.c, rotV(KK.m, tl.rot))
    tl.acted = S.tn
    S.moved = moved
    tone(320 + (S.tn % 5) * 30, 0.1, 0.18)
    afterActivation()
  }
  const afterActivation = () => {
    const mv = new Set(S.moved)
    const el = []
    for (const t of T) {
      if (t.on !== 1 || t.p !== S.turn || t.acted === S.tn) continue
      const [tr, tc] = tgtCell(t)
      if (!inMask(tr, tc)) continue
      const v = at(tr, tc)
      if (!v) continue
      // the manual's law: a chain fires only when a piece is PUSHED INTO the
      // zone — a zone drifting over a piece that sat still stays quiet.
      // (Both-moved row attacks satisfy this too: the victim moved.)
      const sp2 = new Set(S.spun || [])
      if (!mv.has(v.id) && !sp2.has(t.id)) continue
      if (K[t.k].rot) { if (v.id !== t.id) el.push(t.id) }        // Potion chains on any pushed piece
      else if (v.p !== S.turn) el.push(t.id)
    }
    if (el.length === 0) return endTurn()
    if (el.length === 1) { S.autoQ = el[0]; S.autoT = 0; S.phase = 5; return }   // the chain breathes before it strikes
    S.elig = el; S.phase = 1
  }
  const beginActivate = tl => {
    S.elig = []
    const [tr, tc] = tgtCell(tl)
    const vt = at(tr, tc)
    if (!vt) return endTurn()
    if (K[tl.k].dec) { S.pend = { t: tl.id, v: vt.id }; S.phase = 2; return }
    if (K[tl.k].rot) { S.pend = { t: tl.id, v: vt.id, rot: vt.rot }; S.phase = 3; return }
    resolveImpact(tl, vt, 0)
  }
  const lines3 = p => {
    const out = []
    for (let r = 0; r <= 8; r++) for (let c = 0; c <= 8; c++) {
      if (!inMask(r, c)) continue
      for (const d of [[0, 1], [1, 0], [1, 1], [1, -1]]) {
        const cells = [[r, c], [r + d[0], c + d[1]], [r + 2 * d[0], c + 2 * d[1]]]
        if (cells.every(([rr, cc]) => inMask(rr, cc) && at(rr, cc) && at(rr, cc).p === p)) out.push(cells)
      }
    }
    return out
  }
  const endTurn = () => {
    S.phase = 0; S.pend = null; S.elig = []; S.moved = []
    // three hits in one turn breaks a piece — it leaves the dojo for good
    for (const t of T) { if (t.on === 1 && t.hits >= 3) { mark(t); t.on = -1; t.r = t.c = -9; tone(120, 0.4, 0.3, 'sawtooth') } t.hits = 0 }
    const a = lines3(0), b = lines3(1)
    if (a.length && b.length) return crown(2, [], 'both hold Three-In-A-Row — a DRAW, honors even')
    if (a.length) return crown(0, a[0], 'THREE-IN-A-ROW')
    if (b.length) return crown(1, b[0], 'THREE-IN-A-ROW')
    S.turn = 1 - S.turn; S.tn++
    const hand = T.some(t => t.p === S.turn && t.on === 0)
    let room = false
    for (let r = 3; r <= 5; r++) for (let c = 3; c <= 5; c++) if (central9(r, c) && !at(r, c)) room = true
    if (!hand) return crown(1 - S.turn, [], (S.turn ? 'Ohn' : 'Xin') + ' has no pieces left')
    if (!room) return crown(1 - S.turn, [], 'the central nine is sealed')
    S.sel = -1; S.aiT = 0
    if (S.preSel >= 0 && S.turn === S.hseat) { S.sel = S.preSel; S.preSel = -1 }
  }
  const crown = (p, cells, why) => { S.win = p; S.wl = cells; S.why = why; S.phase = 4; tone(p === 2 ? 392 : 523, 0.6, 0.3) }
  const reset = () => { delete wd.__qd }
  const doPlace = (kk, rr, cc, rot) => {
    const tl = T.find(t => t.p === S.turn && t.k === kk && t.on === 0)
    tl.on = 1; tl.r = rr; tl.c = cc; tl.rot = rot; mark(tl)
    S.first = 1; S.sel = -1
    tone(262, 0.12, 0.2)
    const [tr, tc] = tgtCell(tl)
    const vt = inMask(tr, tc) ? at(tr, tc) : null
    const legal = vt && vt.id !== tl.id && (K[tl.k].any ? true : vt.p !== tl.p)
    if (legal) {
      if (K[tl.k].dec) { S.pend = { t: tl.id, v: vt.id }; S.phase = 2 }
      else if (K[tl.k].rot) { S.pend = { t: tl.id, v: vt.id, rot: vt.rot }; S.phase = 3 }
      else resolveImpact(tl, vt, 0)
    } else endTurn()
  }
  const canPlace = (r, c) => inMask(r, c) && central9(r, c) && !at(r, c) && !(!S.first && r === 4 && c === 4)

  // ═══ the DOJO MIND — Ohn played by the machine ═══
  const simClone = () => T.map(t => ({ id: t.id, p: t.p, k: t.k, rot: t.rot, r: t.r, c: t.c, on: t.on, acted: t.acted, hits: t.hits }))
  const sAt = (st, r, c) => st.find(t => t.on === 1 && t.r === r && t.c === c)
  const sLine = (st, r, c, dir) => {
    const seq = []; let rr = r, cc = c
    while (inMask(rr, cc) && sAt(st, rr, cc)) { seq.push(sAt(st, rr, cc)); rr += dir[0]; cc += dir[1] }
    const mv = []
    for (let i = seq.length - 1; i >= 0; i--) {
      const t = seq[i], nr = t.r + dir[0], nc = t.c + dir[1]
      t.hits++
      if (!inMask(nr, nc)) { t.on = 0 } else { t.r = nr; t.c = nc }
      mv.push(t.id)
    }
    return mv
  }
  const sImpact = (st, tl, vt, choice) => {
    const KK = K[tl.k]
    let mv = []
    if (KK.rot) { vt.rot = choice; vt.hits++; mv = [vt.id]; if (vt.p === tl.p) vt.acted = -1 }
    else if (KK.dec) { mv = sLine(st, vt.r, vt.c, rotV(choice === 0 ? [-1, -1] : [1, 1], tl.rot)) }
    else if (KK.both) { const bk = rotV([1, 0], tl.rot), or = tl.r, oc = tl.c; mv = sLine(st, tl.r, tl.c, bk); vt.hits++; vt.r = or; vt.c = oc; mv.push(vt.id) }
    else if (KK.bomb) { const bk = rotV([1, 0], tl.rot), dr = tl.r + bk[0], dc = tl.c + bk[1]
      if (!inMask(dr, dc)) { vt.on = 0; vt.hits++; mv = [vt.id] }
      else { mv = sAt(st, dr, dc) ? sLine(st, dr, dc, bk) : []; vt.r = dr; vt.c = dc; vt.hits++; mv.push(vt.id) } }
    else mv = sLine(st, vt.r, vt.c, rotV(KK.m, tl.rot))
    tl.acted = 9999
    return mv
  }
  const sLines3 = (st, p) => {
    let n = 0
    for (let r = 0; r <= 8; r++) for (let c = 0; c <= 8; c++) {
      if (!inMask(r, c)) continue
      for (const d of [[0, 1], [1, 0], [1, 1], [1, -1]]) {
        const cs = [[r, c], [r + d[0], c + d[1]], [r + 2 * d[0], c + 2 * d[1]]]
        if (cs.every(([rr, cc]) => inMask(rr, cc) && sAt(st, rr, cc) && sAt(st, rr, cc).p === p)) n++
      }
    }
    return n
  }
  const sOpen2 = (st, p) => {
    let n = 0
    for (let r = 0; r <= 8; r++) for (let c = 0; c <= 8; c++) {
      if (!inMask(r, c)) continue
      for (const d of [[0, 1], [1, 0], [1, 1], [1, -1]]) {
        const cs = [[r, c], [r + d[0], c + d[1]], [r + 2 * d[0], c + 2 * d[1]]]
        if (!cs.every(([rr, cc]) => inMask(rr, cc))) continue
        const ts = cs.map(([rr, cc]) => sAt(st, rr, cc))
        const mine = ts.filter(t => t && t.p === p).length
        const empt = ts.filter(t => !t).length
        if (mine === 2 && empt === 1) n++
      }
    }
    return n
  }
  const sScore = st => {
    // Ohn (p1) is the MIND
    const w1 = sLines3(st, ME), w0 = sLines3(st, 1 - ME)
    if (w1 && !w0) return 100000
    if (w0 && !w1) return -100000
    if (w1 && w0) return -200
    let s = sOpen2(st, ME) * 55 - sOpen2(st, 1 - ME) * 70
    for (const t of st) {
      if (t.on !== 1) continue
      const d = Math.abs(t.r - 4) + Math.abs(t.c - 4)
      s += (t.p === ME ? 1 : -1) * Math.max(0, 5 - d) * 3
      if (t.on === 1 && t.hits >= 3) s += t.p === ME ? -60 : 60
    }
    s += st.filter(t => t.p === ME && t.on === 1).length * 4 - st.filter(t => t.p !== ME && t.on === 1).length * 4
    return s
  }
  const sCascade = st => {
    // resolve the MIND's chains greedily until quiet
    let guard = 0, last = st.__mv || []
    while (guard++ < 8) {
      const mv = new Set(last)
      const el = []
      for (const t of st) {
        if (t.on !== 1 || t.p !== ME || t.acted === 9999 || K[t.k].rot) continue
        const o = rotV(K[t.k].t, t.rot); const tr = t.r + o[0], tc = t.c + o[1]
        if (!inMask(tr, tc)) continue
        const v = sAt(st, tr, tc)
        if (v && v.p !== ME && mv.has(v.id)) el.push([t, v])
      }
      if (!el.length) break
      let best = null, bs = -1e9
      for (const [t, v] of el) {
        const chs = K[t.k].dec ? [0, 1] : K[t.k].rot ? [0, 1, 2, 3] : [0]
        for (const ch of chs) {
          const st2 = st.map(x => ({ ...x })); st2.__mv = []
          const t2 = st2.find(x => x.id === t.id), v2 = st2.find(x => x.id === v.id)
          const mv2 = sImpact(st2, t2, v2, ch)
          const sc = sScore(st2)
          if (sc > bs) { bs = sc; best = [t.id, v.id, ch, mv2, st2] }
        }
      }
      if (!best) break
      for (let i = 0; i < st.length; i++) Object.assign(st[i], best[4][i])
      last = best[3]
    }
  }
  const aiMove = () => {
    let best = null, bs = -1e9
    for (let k = 0; k < 12; k++) {
      if (!T.some(t => t.p === ME && t.k === k && t.on === 0)) continue
      for (let rot = 0; rot < 4; rot++) for (let r = 3; r <= 5; r++) for (let c = 3; c <= 5; c++) {
        if (!canPlace(r, c)) continue
        const st = simClone(); st.__mv = []
        const tl = st.find(t => t.p === ME && t.k === k && t.on === 0)
        tl.on = 1; tl.r = r; tl.c = c; tl.rot = rot
        const o = rotV(K[k].t, rot); const vr = r + o[0], vc = c + o[1]
        const vt = inMask(vr, vc) ? sAt(st, vr, vc) : null
        const legal = vt && vt.id !== tl.id && (K[k].any ? true : vt.p !== ME)
        if (legal) {
          const chs = K[k].dec ? [0, 1] : K[k].rot ? [0, 1, 2, 3] : [0]
          for (const ch of chs) {
            const st2 = st.map(x => ({ ...x })); st2.__mv = []
            const t2 = st2.find(x => x.id === tl.id), v2 = st2.find(x => x.id === vt.id)
            st2.__mv = sImpact(st2, t2, v2, ch)
            sCascade(st2)
            const sc = sScore(st2) + (Math.random() - 0.5)
            if (sc > bs) { bs = sc; best = { k, rot, r, c } }
          }
        } else {
          const sc = sScore(st) + (Math.random() - 0.5)
          if (sc > bs) { bs = sc; best = { k, rot, r, c } }
        }
      }
    }
    return best
  }
  const aiChoose = () => {
    // MIND resolves its own live choice phases greedily
    if (S.phase === 1) {
      let bid = S.elig[0], bs = -1e9
      for (const id of S.elig) {
        const st = simClone(); st.__mv = [...S.moved]
        const t2 = st.find(x => x.id === id)
        const o = rotV(K[t2.k].t, t2.rot); const v2 = sAt(st, t2.r + o[0], t2.c + o[1])
        if (!v2) continue
        st.__mv = sImpact(st, t2, v2, 0); sCascade(st)
        const sc = sScore(st)
        if (sc > bs) { bs = sc; bid = id }
      }
      beginActivate(T[bid])
    } else if (S.phase === 2 && S.pend) {
      const tl = T[S.pend.t], vt = T[S.pend.v]
      let bc = 0, bs = -1e9
      for (const ch of [0, 1]) {
        const st = simClone(); st.__mv = []
        st.__mv = sImpact(st, st.find(x => x.id === tl.id), st.find(x => x.id === vt.id), ch)
        const sc = sScore(st)
        if (sc > bs) { bs = sc; bc = ch }
      }
      S.pend = null; S.phase = 0; resolveImpact(tl, vt, bc)
    } else if (S.phase === 3 && S.pend) {
      const tl = T[S.pend.t], vt = T[S.pend.v]
      let bc = vt.rot, bs = -1e9
      for (const ch of [0, 1, 2, 3]) {
        const st = simClone(); st.__mv = []
        st.__mv = sImpact(st, st.find(x => x.id === tl.id), st.find(x => x.id === vt.id), ch)
        sCascade(st)
        const sc = sScore(st)
        if (sc > bs) { bs = sc; bc = ch }
      }
      S.pend = null; S.phase = 0; resolveImpact(tl, vt, bc)
    }
  }
  if (S.phase === 5) {
    S.autoT = (S.autoT || 0) + (dt || 0.016)
    if (S.autoT > 0.6) { const id = S.autoQ; S.autoQ = null; S.phase = 0; beginActivate(T[id]) }
  }
  const ME = S.ai === 1 && S.hseat >= 0 ? 1 - S.hseat : -1
  const aiTurn = ME >= 0 && S.turn === ME && S.win < 0
  if (aiTurn) {
    S.aiT += dt || 0.016
    if (S.aiT > 0.85) {
      S.aiT = 0
      if (S.phase === 0) { const m = aiMove(); if (m) doPlace(m.k, m.r, m.c, m.rot); else crown(1 - ME, [], 'the MIND cannot place — it yields') }
      else aiChoose()
    }
  }

  // ═══ input (humans) ═══
  if (keyEdge('r') && !aiTurn) {
    if (S.phase === 0) S.gr = (S.gr + 1) % 4
    else if (S.phase === 3 && S.pend) { const vt3 = T[S.pend.v]; S.pend.rot = (S.pend.rot + 1) % 4; vt3.rot = S.pend.rot; tone(440, 0.05, 0.12) }
  }
  // CONCEDE — the UI SYSTEM's button delivers wd.__uiClick (the engine already
  // swallowed the canvas press); edge on the timestamp, never the raw click
  if (wd.__uiClickT && wd.__uiClickT !== S.uiT) {
    S.uiT = wd.__uiClickT
    if (wd.__uiClick === 'qd-concede' && S.phase === 0 && !aiTurn && S.win < 0) {
      crown(1 - S.turn, [], (S.turn ? 'Ohn' : 'Xin') + ' concedes with honor')
    }
  }
  if (S.phase === 4) { if (click) reset() }
  else if (click && !aiTurn) {
    // MIND lamp — toggle machine opponent
    if (Math.hypot(mx - 472, my - 40) < 17) { S.ai = S.ai ? 0 : 1; if (S.ai === 1) { S.hseat = T.some(t => t.on === 1) ? S.turn : -1; S.aiT = -1.2 } tone(700, 0.15, 0.2, 'triangle') }
    else if (Math.hypot(mx - 40, my - 466) < 18 && S.phase === 0) { S.gr = (S.gr + 1) % 4; tone(440, 0.06, 0.14) }
    else if (S.phase === 0) {
      let hit2 = null
      const seating = S.ai === 1 && S.hseat < 0
      for (const t of T) if ((seating || t.p === S.turn) && t.on === 0 && Math.abs(mx - t.dx) < 16 && Math.abs(my - t.dy) < 22) hit2 = t
      if (hit2) {
        if (seating) {
          // the first tile you touch declares your color — the MIND takes the rest
          S.hseat = hit2.p; S.aiT = -0.9
          if (hit2.p === S.turn) { S.sel = hit2.k; S.gr = 0 } else { S.preSel = hit2.k }
          tone(523, 0.15, 0.2)
        } else if (hit2.p === S.turn) { S.sel = hit2.k; S.gr = 0; tone(392, 0.07, 0.14) }
      }
      else if (S.sel >= 0) {
        const c = Math.round((mx - 256) / CELL) + 4, r = Math.round((my - 252) / CELL) + 4
        if (canPlace(r, c)) doPlace(S.sel, r, c, S.gr)   // hover is the preview; one click speaks
      }
    }
    else if (S.phase === 1) {
      for (const id of S.elig) { const t = T[id]; const [a, b] = px(t.r, t.c); if (Math.hypot(mx - a, my - b) < 22) { beginActivate(t); break } }
    }
    else if (S.phase === 2 && S.pend) {
      const tl = T[S.pend.t], vt = T[S.pend.v]
      for (const ch of [0, 1]) {
        const d = rotV(ch === 0 ? [-1, -1] : [1, 1], tl.rot)
        const [a, b] = px(vt.r + d[0], vt.c + d[1])
        if (Math.hypot(mx - a, my - b) < 22) { S.pend = null; S.phase = 0; resolveImpact(tl, vt, ch); break }
      }
    }
    else if (S.phase === 3 && S.pend) {
      // Potion: R turns the caught piece · any click settles it
      const tl = T[S.pend.t], vt = T[S.pend.v]
      S.pend = null; S.phase = 0; resolveImpact(tl, vt, vt.rot)
    }
  }
  S.mh = md ? 1 : 0

  // ═══ publish ═══
  const U = new Array(256).fill(0)
  U[0] = S.turn; U[1] = S.phase; U[2] = S.sel; U[5] = S.gr; U[6] = S.win
  U[7] = now; U[8] = S.ai; U[9] = S.first
  U[10] = hasM ? mx : -99; U[11] = hasM ? my : -99
  for (let i = 0; i < 3; i++) { const c = S.wl[i]; U[12 + i * 2] = c ? c[0] : -9; U[13 + i * 2] = c ? c[1] : -9 }
  if (S.phase === 2 && S.pend) {
    const tl = T[S.pend.t], vt = T[S.pend.v]
    for (const ch of [0, 1]) { const d = rotV(ch === 0 ? [-1, -1] : [1, 1], tl.rot); U[18 + ch * 2] = vt.r + d[0]; U[19 + ch * 2] = vt.c + d[1] }
  } else { U[18] = -9; U[20] = -9 }
  U[3] = -9; U[4] = -9; U[24] = -9; U[25] = -9; U[26] = 0
  if (S.phase === 0 && S.sel >= 0 && !aiTurn) {
    let gr2 = -9, gc2 = -9
    if (hasM) { const c = Math.round((mx - 256) / CELL) + 4, r = Math.round((my - 252) / CELL) + 4; if (canPlace(r, c)) { gr2 = r; gc2 = c } }
    if (gr2 > -5) {
      U[3] = gr2; U[4] = gc2
      const o = rotV(K[S.sel].t, S.gr)
      if (inMask(gr2 + o[0], gc2 + o[1])) { U[24] = gr2 + o[0]; U[25] = gc2 + o[1] }
    }
  }
  U[23] = S.sel; U[27] = 0
  const eset = new Set(S.elig)
  for (const t of T) {
    const B = 30 + t.id * 7
    let tx = t.dx, ty = t.dy
    if (t.on === 1) { const [a, b] = px(t.r, t.c); tx = a; ty = b }
    if (t.on === -1) { tx = -200; ty = -200 }
    const kk = Math.min(1, (now - t.at) / 0.5)
    const e = 1 - (1 - kk) * (1 - kk)
    U[B] = t.ax + (tx - t.ax) * e; U[B + 1] = t.ay + (ty - t.ay) * e
    U[B + 2] = t.rot; U[B + 3] = t.k; U[B + 4] = t.p
    U[B + 5] = t.on === -1 ? 0 : t.on === 1 ? 1 : 0.5
    U[B + 6] = eset.has(t.id) || S.autoQ === t.id ? 1 : (S.pend && S.pend.v === t.id ? 2 : (t.on === 0 && t.k === S.sel && t.p === S.turn ? 3 : 0))
  }
  wd.gpuUniforms = U
  if (typeof window !== 'undefined') window.__qd = { turn: S.turn, phase: S.phase, sel: S.sel, stage: S.stage || null, ai: S.ai, tiles: T.filter(t => t.on === 1).map(t => ({ id: t.id, p: t.p, k: t.k, rot: t.rot, r: t.r, c: t.c })) }

  const who = S.turn === 0 ? 'XIN' : 'OHN'
  const XCOL = '#ff4d8a', OCOL = '#9edb2f', GOLDC = '#ffd27a', DIM = '#c9b896'
  let l1 = '', l2 = ''
  if (S.phase === 0) {
    if (aiTurn) { l1 = 'the DOJO MIND (' + (ME === 0 ? 'Xin' : 'Ohn') + ') considers…'; l2 = 'you hold ' + (ME === 0 ? 'OHN' : 'XIN') }
    else { l1 = who + ' — place a piece'; l2 = S.sel >= 0 ? 'R rotates · hover previews the strike · click places' : (S.ai === 1 && S.hseat < 0 ? 'pick ANY tile — its color becomes yours' : S.ai === 0 ? 'two humans at one table' : 'choose a weapon from your side') }
  }
  else if (S.phase === 1) { l1 = who + ' — chain attack'; l2 = aiTurn ? '' : 'click ONE flashing piece to strike' }
  else if (S.phase === 2) { l1 = who + ' — Kunai'; l2 = aiTurn ? '' : 'click the square to push them into' }
  else if (S.phase === 3) { l1 = who + ' — Potion'; l2 = aiTurn ? '' : 'R turns the caught piece · click settles it' }
  else if (S.phase === 5) { l1 = who + ' — the chain flows…'; l2 = '' }
  else if (S.phase === 4) { l1 = S.win === 2 ? 'A DRAW — ' + S.why : (S.win === 0 ? 'XIN' : 'OHN') + ' WINS — ' + S.why; l2 = 'click anywhere for a new duel' }

  // the weapon the panel TEACHES: selection first, else the piece under the cursor
  let teach = S.sel >= 0 && S.phase === 0 ? { k: S.sel, p: S.turn } : null
  if (!teach && hasM && S.win < 0) {
    for (const t of T) {
      if (t.on === 1) { const [a2, b2] = px(t.r, t.c); if (Math.abs(mx - a2) < 19 && Math.abs(my - b2) < 19) teach = { k: t.k, p: t.p } }
      else if (t.on === 0) { if (Math.abs(mx - t.dx) < 15 && Math.abs(my - t.dy) < 20) teach = { k: t.k, p: t.p } }
    }
  }

  wd.hud = []
  wd.ui = { rev: 1, theme: { border: 'rgba(255,190,110,0.35)', bg: 'rgba(10,8,14,0.66)' }, root: [
    // ── STATUS — one organized banner, whose ink is the mover's color
    { id: 'qd-status', kind: 'panel', anchor: { gx: 256, gy: 6 }, align: 'tc', w: '52%', gap: 1.5, pad: 5, children: [
      { id: 'qd1', kind: 'text', textAlign: 'center', fontSize: 13, color: S.phase === 4 ? GOLDC : (S.turn === 0 ? XCOL : OCOL), text: l1 },
      ...(l2 ? [{ id: 'qd2', kind: 'text', wrap: true, textAlign: 'center', fontSize: 8.5, color: DIM, text: l2 }] : []),
    ] },
    // ── hand headers — the two sides named
    { id: 'qd-xin', kind: 'panel', anchor: { gx: 37, gy: 86 }, glass: false, draggable: false, w: 'auto', children: [
      { kind: 'text', text: 'XIN', fontSize: 12, color: XCOL } ] },
    { id: 'qd-ohn', kind: 'panel', anchor: { gx: 474, gy: 86 }, align: 'tc', glass: false, draggable: false, w: 'auto', children: [
      { kind: 'text', text: 'OHN', fontSize: 12, color: OCOL } ] },
    // ── WEAPON PANEL — the teaching card (bottom-left, clear of board + hands)
    ...(teach ? [{ id: 'qd-weapon', kind: 'panel', anchor: { gx: 78, gy: 432 }, align: 'tl', w: '31%', gap: 2, pad: 5,
      glass: { border: (teach.p === 0 ? 'rgba(255,77,138,0.55)' : 'rgba(158,219,47,0.55)') }, children: [
      { id: 'qdw-n', kind: 'text', text: K[teach.k].n.toUpperCase(), fontSize: 10.5, color: teach.p === 0 ? XCOL : OCOL },
      { id: 'qdw-d', kind: 'text', wrap: true, text: KD[teach.k], fontSize: 8, color: '#e8ddc8' },
    ] }] : []),
    // ── CONCEDE — a real labeled button (Galen: "concede button is empty of text")
    ...(S.phase === 0 && !aiTurn && S.win < 0 ? [{ id: 'qd-cpanel', kind: 'panel', anchor: { gx: 256, gy: 484 }, w: 'auto', pad: 2,
      glass: { border: 'rgba(200,170,120,0.5)' }, children: [
      { id: 'qd-concede', kind: 'button', text: 'CONCEDE WITH HONOR', fontSize: 9, color: '#e8cfa0', click: 'qd-concede' } ] }] : []),
    // ── control captions over the shader seats (lamp + rotate ring)
    { id: 'qd-lamp', kind: 'panel', anchor: { gx: 472, gy: 58 }, align: 'tc', glass: false, draggable: false, w: 'auto', children: [
      { kind: 'text', text: S.ai === 1 ? 'MIND ON' : 'MIND OFF', fontSize: 7.5, color: S.ai === 1 ? OCOL : '#8a8070' } ] },
    { id: 'qd-rot', kind: 'panel', anchor: { gx: 40, gy: 438 }, align: 'tc', glass: false, draggable: false, w: 'auto', children: [
      { kind: 'text', text: 'R · rotate', fontSize: 7.5, color: '#b8a880' } ] },
  ] }

} catch (e) {
  sim.worldData.hud = [{ id: 'qderr', type: 'text', x: '14px', y: '104px', color: '#ff6060', fontSize: '12px', text: 'HOOK ERR: ' + String(e).slice(0, 120) }]
}