Ben Blaker Claude Opus 4.8 (1M context) commited on
Commit
45f65df
·
unverified ·
1 Parent(s): e98d44f

feat(game): 3D keycap board, fleshed character, context-overflow death (#3)

Browse files

- Tiles are now 3D buttons: a shared ExtrudeGeometry of the rounded
hand-drawn outline (no square slab) with ink-rimmed cap, hatched paper
sides, and a bold bottom silhouette line — one drawn object. Keycaps
depress and spring back when landed on.
- Board leans away 22 degrees from vertical under a perspective camera
viewed slightly from below, so button depth and presses read clearly.
- Character is a fleshed doodle (tube limbs, paper-filled bean torso,
scarf) with pose frames: idle, think, hop-up/mid/land crouch-tuck-bend,
dead-dizzy and dead (X eyes). Billboard now composes inverse-parent
rotation and a charTilt spin channel so wobbles/spins render on the
tilted board.
- HUD reads as an LLM context window: "context window: N/10" counter
plus ten token slots that fill with landed words; red warning at 10/10.
- Exceeding the context window now KILLS the character (crumple, slide
off the board, fade) and shows a "context exceeded!" card without ever
calling the judge; landing on <eos> within budget still submits.
- Hop horizontal motion is fast-out (power3.out) instead of linear for a
snappier feel; one-deep input buffer preserved.
- Canvas textures bake at devicePixelRatio so words and ink stay crisp
on hidpi screens.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (3) hide show
  1. static/game.html +5 -1
  2. static/game.js +450 -110
  3. static/style.css +43 -11
static/game.html CHANGED
@@ -2,7 +2,11 @@
2
  <div class="sq-hud">
3
  <div class="sq-title">semantique</div>
4
  <div class="sq-target">target: <span class="sq-target-word"></span></div>
5
- <div class="sq-pips" aria-label="context window"></div>
 
 
 
 
6
  </div>
7
  <div class="sq-stage"></div>
8
  <div class="sq-hint">arrow keys / WASD to hop</div>
 
2
  <div class="sq-hud">
3
  <div class="sq-title">semantique</div>
4
  <div class="sq-target">target: <span class="sq-target-word"></span></div>
5
+ <div class="sq-context" aria-label="context window">
6
+ <span class="sq-context-label">context window:</span>
7
+ <span class="sq-context-count"><b class="sq-ctx-used">0</b>/<b class="sq-ctx-cap"></b></span>
8
+ <div class="sq-slots"></div>
9
+ </div>
10
  </div>
11
  <div class="sq-stage"></div>
12
  <div class="sq-hint">arrow keys / WASD to hop</div>
static/game.js CHANGED
@@ -14,14 +14,53 @@
14
  const INK_SOFT = "#5a564c";
15
 
16
  // ---- HUD ----
 
 
17
  element.querySelector(".sq-target-word").textContent = level.target;
18
- const pipsEl = element.querySelector(".sq-pips");
 
 
 
19
  for (let i = 0; i < level.budget; i++) {
20
- const pip = document.createElement("span");
21
- pip.className = "sq-pip";
22
- pip.style.setProperty("--tilt", `${(Math.random() * 8 - 4).toFixed(1)}deg`);
23
- pipsEl.appendChild(pip);
24
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  // Handwritten font must be loaded before we bake it into canvas textures.
27
  await Promise.all([
@@ -67,16 +106,27 @@
67
  ctx.closePath();
68
  }
69
 
70
- const TILE_PX = 384; // texture resolution per tile
 
 
 
71
 
72
  function drawTileCanvas(ctx, word) {
 
73
  const special = word === "<bos>" || word === "<eos>";
74
- ctx.clearRect(0, 0, TILE_PX, TILE_PX);
 
 
 
75
  ctx.lineJoin = "round";
76
  ctx.lineCap = "round";
77
 
78
- // paper fill so tiles sit on the ruled background
79
- wobblyRoundRect(ctx, 26, 26, TILE_PX - 52, TILE_PX - 52, 56, 3);
 
 
 
 
80
  ctx.fillStyle = "rgba(255, 253, 247, 0.92)";
81
  ctx.fill();
82
 
@@ -86,7 +136,7 @@
86
  ctx.lineWidth = 7;
87
  ctx.stroke();
88
  ctx.setLineDash([]);
89
- wobblyRoundRect(ctx, 26, 26, TILE_PX - 52, TILE_PX - 52, 56, 4.5);
90
  ctx.strokeStyle = special ? "rgba(90,86,76,0.35)" : "rgba(28,27,24,0.35)";
91
  ctx.lineWidth = 4;
92
  ctx.stroke();
@@ -100,21 +150,54 @@
100
  ctx.fillText(word, TILE_PX / 2 + rand(-2, 2), TILE_PX / 2 + rand(-1, 3));
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  // ---- three.js paper scene ----
104
  const scene = new THREE.Scene(); // transparent: CSS paper + ruled lines show through
105
 
106
- const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
107
- camera.position.set(0, 10, 7);
108
- camera.lookAt(0, 0, 0);
109
 
110
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
111
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
112
  stage.appendChild(renderer.domElement);
113
 
 
 
 
 
 
 
114
  // Board group: everything sits here, slightly slanted like a sketch.
115
  const board = new THREE.Group();
116
- board.rotation.y = THREE.MathUtils.degToRad(1.5);
117
- scene.add(board);
118
 
119
  // ---- tiles ----
120
  const SPACING = 1.5;
@@ -124,12 +207,65 @@
124
  z: (r - (ROWS - 1) / 2) * SPACING,
125
  });
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  const tiles = []; // { mesh, ctx, texture, word, row, col }
128
  for (let r = 0; r < ROWS; r++) {
129
  for (let c = 0; c < COLS; c++) {
130
  const word = level.grid[r][c];
131
  const canvas = document.createElement("canvas");
132
- canvas.width = canvas.height = TILE_PX;
133
  const ctx = canvas.getContext("2d");
134
  drawTileCanvas(ctx, word);
135
 
@@ -137,19 +273,26 @@
137
  texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
138
  texture.colorSpace = THREE.SRGBColorSpace;
139
 
140
- const mesh = new THREE.Mesh(
141
- new THREE.PlaneGeometry(TILE_SIZE, TILE_SIZE),
142
- new THREE.MeshBasicMaterial({ map: texture, transparent: true })
143
- );
144
- mesh.rotation.x = -Math.PI / 2; // lie flat on the table
145
  const { x, z } = tileAt(r, c);
146
- mesh.position.set(x, 0, z);
147
  board.add(mesh);
148
  tiles.push({ mesh, ctx, texture, word, row: r, col: c });
149
  }
150
  }
151
  const tile = (r, c) => tiles[r * COLS + c];
152
 
 
 
 
 
 
 
 
153
  // ---- character: a line-doodle on a billboarded plane ----
154
 
155
  function wobblyCircle(ctx, cx, cy, r, jitter) {
@@ -175,8 +318,50 @@
175
  const CHAR_PX_W = 256;
176
  const CHAR_PX_H = 320;
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  function drawCharCanvas(ctx, pose) {
179
  const J = 2.5;
 
180
  ctx.clearRect(0, 0, CHAR_PX_W, CHAR_PX_H);
181
  ctx.lineJoin = "round";
182
  ctx.lineCap = "round";
@@ -184,78 +369,183 @@
184
  ctx.fillStyle = INK;
185
  ctx.lineWidth = 7;
186
 
187
- // head
188
- wobblyCircle(ctx, 128, 96, 50, 2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  ctx.stroke();
190
- ctx.fillStyle = "rgba(255,253,247,0.9)";
 
 
 
191
  ctx.fill();
 
 
 
 
 
 
 
192
  ctx.fillStyle = INK;
193
 
194
- // face
195
- const blink = pose === "idle" && Math.random() < 0.18;
196
- if (blink) {
197
- wobblyLine(ctx, 102, 92, 118, 92, 1); ctx.stroke();
198
- wobblyLine(ctx, 138, 92, 154, 92, 1); ctx.stroke();
 
 
199
  } else {
200
- ctx.beginPath(); ctx.arc(110, 90, 5.5, 0, Math.PI * 2); ctx.fill();
201
- ctx.beginPath(); ctx.arc(146, 90, 5.5, 0, Math.PI * 2); ctx.fill();
202
  }
203
- ctx.beginPath();
204
- if (pose === "think") {
205
- ctx.arc(128, 116, 9, 0, Math.PI * 2); // little "o" mouth
 
 
 
206
  ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  } else {
208
- ctx.arc(128, 104, 22, Math.PI * 0.18, Math.PI * 0.82); // smile
 
209
  ctx.stroke();
210
  }
211
 
212
- // body
213
- wobblyLine(ctx, 128, 146, 128, 236, J); ctx.stroke();
214
-
215
- // arms + legs by pose
216
- if (pose === "hop") {
217
- wobblyLine(ctx, 128, 172, 76, 122, J); ctx.stroke();
218
- wobblyLine(ctx, 128, 172, 180, 122, J); ctx.stroke();
219
- wobblyLine(ctx, 128, 236, 96, 270, J); ctx.stroke();
220
- wobblyLine(ctx, 128, 236, 162, 268, J); ctx.stroke();
221
- } else if (pose === "think") {
222
- wobblyLine(ctx, 128, 176, 88, 210, J); ctx.stroke();
223
- wobblyLine(ctx, 128, 176, 158, 142, J); ctx.stroke(); // hand to chin
224
- wobblyLine(ctx, 128, 236, 104, 302, J); ctx.stroke();
225
- wobblyLine(ctx, 128, 236, 152, 302, J); ctx.stroke();
226
  ctx.font = '400 56px "Patrick Hand"';
227
  ctx.textAlign = "center";
228
  ctx.fillText("?", 196 + rand(-2, 2), 56 + rand(-2, 2));
229
- } else {
230
- wobblyLine(ctx, 128, 176, 90, 216, J); ctx.stroke();
231
- wobblyLine(ctx, 128, 176, 166, 216, J); ctx.stroke();
232
- wobblyLine(ctx, 128, 236, 104, 302, J); ctx.stroke();
233
- wobblyLine(ctx, 128, 236, 152, 302, J); ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
234
  }
235
  }
236
 
237
  const charCanvas = document.createElement("canvas");
238
- charCanvas.width = CHAR_PX_W;
239
- charCanvas.height = CHAR_PX_H;
240
  const charCtx = charCanvas.getContext("2d");
241
  let charPose = "idle";
242
  drawCharCanvas(charCtx, charPose);
243
 
244
  const charTexture = new THREE.CanvasTexture(charCanvas);
245
  charTexture.colorSpace = THREE.SRGBColorSpace;
246
- const CHAR_BASE_Y = 0.66;
247
- const CHAR_Z_OFF = -0.34; // stand toward the tile's top edge, not on the word
 
 
248
  const charMesh = new THREE.Mesh(
249
- new THREE.PlaneGeometry(0.86, 1.075),
250
  new THREE.MeshBasicMaterial({ map: charTexture, transparent: true })
251
  );
 
 
252
  const charPosFor = (r, c) => {
253
  const { x, z } = tileAt(r, c);
254
  return { x, z: z + CHAR_Z_OFF };
255
  };
 
256
  const startPos = charPosFor(level.start[0], level.start[1]);
257
- charMesh.position.set(startPos.x, CHAR_BASE_Y, startPos.z);
258
- board.add(charMesh);
 
 
259
 
260
  function setPose(pose) {
261
  charPose = pose;
@@ -270,6 +560,8 @@
270
  drawTileCanvas(t.ctx, t.word);
271
  t.texture.needsUpdate = true;
272
  }
 
 
273
  drawCharCanvas(charCtx, charPose);
274
  charTexture.needsUpdate = true;
275
  }, 340);
@@ -278,9 +570,8 @@
278
 
279
  const chipsEl = element.querySelector(".sq-chips");
280
  const hintEl = element.querySelector(".sq-hint");
281
- const pips = [...pipsEl.children];
282
 
283
- let state = "idle"; // idle | hopping | judging | verdict
284
  let pos = [...level.start];
285
  let words = []; // appended (non-structural) words
286
  let used = 0; // hops spent
@@ -296,45 +587,53 @@
296
 
297
  function bonk() {
298
  gsap.timeline()
299
- .to(charMesh.rotation, { z: -0.13, duration: 0.05 })
300
- .to(charMesh.rotation, { z: 0.13, duration: 0.09, repeat: 2, yoyo: true })
301
- .to(charMesh.rotation, { z: 0, duration: 0.05 });
302
  }
303
 
304
  function hopTo(r, c) {
305
  state = "hopping";
306
  const { x, z } = charPosFor(r, c);
307
- setPose("hop");
308
  gsap.timeline({ onComplete: () => land(r, c) })
309
- .to(charMesh.scale, { x: 1.18, y: 0.78, duration: 0.09, ease: "power1.in" }) // anticipation squash
310
- .to(charMesh.scale, { x: 0.94, y: 1.12, duration: 0.1 })
311
- .to(charMesh.position, { x, z, duration: 0.27, ease: "none" }, 0.09)
312
- .to(charMesh.position, { y: CHAR_BASE_Y + 0.8, duration: 0.14, ease: "power2.out" }, 0.09)
313
- .to(charMesh.position, { y: CHAR_BASE_Y, duration: 0.13, ease: "power2.in" }, 0.23)
314
- .to(charMesh.scale, { x: 1.1, y: 0.86, duration: 0.07 }, 0.36) // landing squash
315
- .to(charMesh.scale, { x: 1, y: 1, duration: 0.12, ease: "back.out(3)" }, 0.43);
 
 
 
316
  }
317
 
318
  function land(r, c) {
319
  pos = [r, c];
320
  used += 1;
321
- if (pips[used - 1]) pips[used - 1].classList.add("sq-used");
322
- setPose("idle");
323
 
324
- // landed-tile stamp pulse
325
  const t = tile(r, c);
326
- gsap.timeline()
327
- .to(t.mesh.scale, { x: 1.07, y: 1.07, duration: 0.09 })
328
- .to(t.mesh.scale, { x: 1, y: 1, duration: 0.18, ease: "back.out(2)" });
329
-
330
  const word = t.word;
 
 
 
 
 
 
 
331
  if (word !== "<bos>" && word !== "<eos>") {
332
  words.push(word);
333
  addChip(word);
334
  }
335
 
336
- if (word === "<eos>") return submit(false);
337
- if (used >= level.budget) return submit(true);
 
 
 
 
338
  state = "idle";
339
  if (buffered) {
340
  const dir = buffered;
@@ -343,6 +642,24 @@
343
  }
344
  }
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  // ---- judging + verdict ----
347
 
348
  const overlayEl = element.querySelector(".sq-overlay");
@@ -353,16 +670,14 @@
353
  const retryBtn = element.querySelector(".sq-retry");
354
  const againBtn = element.querySelector(".sq-again");
355
 
356
- async function submit(overflow) {
357
  state = "judging";
358
  buffered = null;
359
  setPose("think");
360
- hintEl.textContent = overflow
361
- ? "context window overflowed! the judge reads it anyway…"
362
- : "the judge is reading…";
363
  gsap.to(hintEl, { opacity: 1, duration: 0.3 });
364
  const bob = gsap.to(charMesh.position, {
365
- y: CHAR_BASE_Y + 0.12, duration: 0.55, repeat: -1, yoyo: true, ease: "sine.inOut",
366
  });
367
  const slowTimer = setTimeout(() => {
368
  hintEl.textContent = "the tiny model is stretching its parameters…";
@@ -376,10 +691,10 @@
376
  }
377
  clearTimeout(slowTimer);
378
  bob.kill();
379
- gsap.to(charMesh.position, { y: CHAR_BASE_Y, duration: 0.2 });
380
 
381
- if (!res || !res.ok) return judgeFailed(overflow, res && res.error);
382
- showVerdict(res, overflow);
383
  }
384
 
385
  function openCard() {
@@ -392,7 +707,7 @@
392
  );
393
  }
394
 
395
- function judgeFailed(overflow, error) {
396
  console.warn("judge failed:", error);
397
  sentenceEl.textContent = "the judge dozed off…";
398
  barsEl.innerHTML = "";
@@ -401,26 +716,38 @@
401
  againBtn.classList.add("sq-hidden");
402
  retryBtn.onclick = () => {
403
  overlayEl.classList.add("sq-hidden");
404
- submit(overflow);
405
  };
406
  openCard();
407
  }
408
 
409
- function showVerdict(res, overflow) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  state = "verdict";
411
- setPose(res.verdict === "win" ? "hop" : "idle");
412
  hintEl.textContent = "";
413
 
414
  sentenceEl.innerHTML = "";
415
  const quote = document.createElement("div");
416
  quote.textContent = `“${res.sentence}”`;
417
  sentenceEl.appendChild(quote);
418
- if (overflow) {
419
- const note = document.createElement("div");
420
- note.textContent = "— context window overflowed —";
421
- note.style.cssText = "font-family:'Patrick Hand',cursive;font-size:17px;color:#b3402e;";
422
- sentenceEl.appendChild(note);
423
- }
424
 
425
  // probability bars, sorted by prob so the verdict reads top-down
426
  barsEl.innerHTML = "";
@@ -485,7 +812,7 @@
485
  used = 0;
486
  pos = [...level.start];
487
  [...chipsEl.querySelectorAll(".sq-chip:not(.sq-chip-bos)")].forEach((c) => c.remove());
488
- pips.forEach((p) => p.classList.remove("sq-used"));
489
  hintEl.textContent = "arrow keys / WASD to hop";
490
  gsap.to(hintEl, { opacity: 1, duration: 0.4 });
491
 
@@ -494,7 +821,10 @@
494
  gsap.timeline({ onComplete: () => (state = "idle") })
495
  .to(charMesh.scale, { x: 0, y: 0, duration: 0.18, ease: "power2.in" })
496
  .add(() => {
497
- charMesh.position.set(home.x, CHAR_BASE_Y, home.z);
 
 
 
498
  setPose("idle");
499
  })
500
  .to(charMesh.scale, { x: 1, y: 1, duration: 0.28, ease: "back.out(2.5)" });
@@ -537,19 +867,29 @@
537
  const w = stage.clientWidth || 1;
538
  const h = stage.clientHeight || 1;
539
  const aspect = w / h;
540
- const vh = Math.max(VIEW, 6.8 / aspect); // keep the board inside narrow windows
541
- camera.left = (-vh * aspect) / 2;
542
- camera.right = (vh * aspect) / 2;
543
- camera.top = vh / 2;
544
- camera.bottom = -vh / 2;
545
  camera.updateProjectionMatrix();
546
  renderer.setSize(w, h);
547
  }
548
  new ResizeObserver(resize).observe(stage);
549
  resize();
550
 
 
 
 
 
 
 
551
  renderer.setAnimationLoop(() => {
552
- charMesh.quaternion.copy(camera.quaternion); // billboard the doodle
 
 
 
 
553
  renderer.render(scene, camera);
554
  });
555
 
 
14
  const INK_SOFT = "#5a564c";
15
 
16
  // ---- HUD ----
17
+ // The context window: "N/budget" counter + one token slot per hop, so the
18
+ // player literally watches their context fill up word by word.
19
  element.querySelector(".sq-target-word").textContent = level.target;
20
+ const countEl = element.querySelector(".sq-context-count");
21
+ const usedEl = element.querySelector(".sq-ctx-used");
22
+ element.querySelector(".sq-ctx-cap").textContent = level.budget;
23
+ const slotsEl = element.querySelector(".sq-slots");
24
  for (let i = 0; i < level.budget; i++) {
25
+ const slot = document.createElement("span");
26
+ slot.className = "sq-slot";
27
+ slot.style.setProperty("--tilt", `${(Math.random() * 8 - 4).toFixed(1)}deg`);
28
+ slotsEl.appendChild(slot);
29
  }
30
+ const slotWord = (w) =>
31
+ w === "<bos>" || w === "<eos>" ? "·" : w.length > 5 ? w.slice(0, 4) + "…" : w;
32
+ const hud = {
33
+ update(used, word) {
34
+ usedEl.textContent = used;
35
+ const slot = slotsEl.children[used - 1];
36
+ if (!slot) return;
37
+ slot.classList.add("sq-filled");
38
+ slot.textContent = slotWord(word);
39
+ gsap.from(slot, { scale: 0, duration: 0.3, ease: "back.out(2.5)" });
40
+ },
41
+ warnFull() {
42
+ countEl.classList.add("sq-full");
43
+ gsap.to(countEl, { x: "+=3", yoyo: true, repeat: 5, duration: 0.05 });
44
+ },
45
+ overflow() {
46
+ usedEl.textContent = level.budget + 1;
47
+ countEl.classList.add("sq-full");
48
+ const burst = document.createElement("span");
49
+ burst.className = "sq-slot sq-burst";
50
+ burst.textContent = "✕";
51
+ slotsEl.appendChild(burst);
52
+ gsap.to(burst, { y: 10, rotation: 35, opacity: 0, duration: 0.8, ease: "power1.in" });
53
+ },
54
+ reset() {
55
+ usedEl.textContent = 0;
56
+ countEl.classList.remove("sq-full");
57
+ slotsEl.querySelector(".sq-burst")?.remove();
58
+ for (const slot of slotsEl.children) {
59
+ slot.classList.remove("sq-filled");
60
+ slot.textContent = "";
61
+ }
62
+ },
63
+ };
64
 
65
  // Handwritten font must be loaded before we bake it into canvas textures.
66
  await Promise.all([
 
106
  ctx.closePath();
107
  }
108
 
109
+ const TILE_PX = 384; // logical texture units per tile
110
+ // Canvas textures are baked at devicePixelRatio so words and ink lines stay
111
+ // crisp on hidpi screens (all drawing keeps using logical coordinates).
112
+ const TEX_SCALE = Math.min(window.devicePixelRatio || 1, 2);
113
 
114
  function drawTileCanvas(ctx, word) {
115
+ ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0);
116
  const special = word === "<bos>" || word === "<eos>";
117
+ // opaque paper base — the keycap body is the rounded outline itself now,
118
+ // so this whole canvas IS the cap face (extruded silhouette clips it)
119
+ ctx.fillStyle = "#faf8f2";
120
+ ctx.fillRect(0, 0, TILE_PX, TILE_PX);
121
  ctx.lineJoin = "round";
122
  ctx.lineCap = "round";
123
 
124
+ // the ink line sits AT the geometry's rim — outer jitter clips against the
125
+ // extruded silhouette, so the drawn outline and the 3D edge are one line
126
+ const inset = 5;
127
+ const span = TILE_PX - inset * 2;
128
+ const cr = Math.round(span * 0.17);
129
+ wobblyRoundRect(ctx, inset, inset, span, span, cr, 3);
130
  ctx.fillStyle = "rgba(255, 253, 247, 0.92)";
131
  ctx.fill();
132
 
 
136
  ctx.lineWidth = 7;
137
  ctx.stroke();
138
  ctx.setLineDash([]);
139
+ wobblyRoundRect(ctx, inset, inset, span, span, cr, 4.5);
140
  ctx.strokeStyle = special ? "rgba(90,86,76,0.35)" : "rgba(28,27,24,0.35)";
141
  ctx.lineWidth = 4;
142
  ctx.stroke();
 
150
  ctx.fillText(word, TILE_PX / 2 + rand(-2, 2), TILE_PX / 2 + rand(-1, 3));
151
  }
152
 
153
+ // Keycap sides: same paper as the cap so the button reads as ONE drawn
154
+ // object — hatch shading for depth and a bold ink line along the bottom
155
+ // silhouette (the cap's ring already inks the top edge; no second rim).
156
+ const SIDE_PX_W = 384;
157
+ const SIDE_PX_H = 128;
158
+
159
+ function drawTileSideCanvas(ctx) {
160
+ ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0);
161
+ ctx.fillStyle = "#faf8f2";
162
+ ctx.fillRect(0, 0, SIDE_PX_W, SIDE_PX_H);
163
+ ctx.lineJoin = "round";
164
+ ctx.lineCap = "round";
165
+
166
+ ctx.strokeStyle = "rgba(28,27,24,0.13)";
167
+ ctx.lineWidth = 5;
168
+ for (let x = rand(8, 44); x < SIDE_PX_W; x += rand(52, 84)) {
169
+ wobblyLine(ctx, x, SIDE_PX_H - 12, x + 67, 14, 2); // ~-55° up-right
170
+ ctx.stroke();
171
+ }
172
+
173
+ // bottom outline — wraps the whole silhouette via the side-wall UVs
174
+ ctx.strokeStyle = INK;
175
+ ctx.lineWidth = 7;
176
+ wobblyLine(ctx, -6, SIDE_PX_H - 7, SIDE_PX_W + 6, SIDE_PX_H - 7, 2.5);
177
+ ctx.stroke();
178
+ }
179
+
180
  // ---- three.js paper scene ----
181
  const scene = new THREE.Scene(); // transparent: CSS paper + ruled lines show through
182
 
183
+ const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100);
184
+ camera.position.set(0, -0.9, 11.5); // slightly below-front: keycap sides read
185
+ camera.lookAt(0, 0.25, 0);
186
 
187
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
188
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
189
  stage.appendChild(renderer.domElement);
190
 
191
+ // Tilt group: the whole board leans away from the viewer, 22° back from
192
+ // vertical, like a keyboard propped up on a desk.
193
+ const boardTilt = new THREE.Group();
194
+ boardTilt.rotation.x = THREE.MathUtils.degToRad(68);
195
+ scene.add(boardTilt);
196
+
197
  // Board group: everything sits here, slightly slanted like a sketch.
198
  const board = new THREE.Group();
199
+ board.rotation.z = THREE.MathUtils.degToRad(1.5);
200
+ boardTilt.add(board);
201
 
202
  // ---- tiles ----
203
  const SPACING = 1.5;
 
207
  z: (r - (ROWS - 1) / 2) * SPACING,
208
  });
209
 
210
+ // Tiles are keycaps: an extruded rounded-rect silhouette — the 3D body
211
+ // follows the drawn outline directly (no square box behind it). The TOP
212
+ // FACE sits at y=0, so the old "tile surface" coordinates still mean the
213
+ // same thing.
214
+ const TILE_DEPTH = 0.42;
215
+ const TILE_R = TILE_SIZE * 0.17; // corner radius — the ink line traces it
216
+ const tileShape = new THREE.Shape();
217
+ {
218
+ const h = TILE_SIZE / 2;
219
+ tileShape.moveTo(-h + TILE_R, -h);
220
+ tileShape.lineTo(h - TILE_R, -h);
221
+ tileShape.quadraticCurveTo(h, -h, h, -h + TILE_R);
222
+ tileShape.lineTo(h, h - TILE_R);
223
+ tileShape.quadraticCurveTo(h, h, h - TILE_R, h);
224
+ tileShape.lineTo(-h + TILE_R, h);
225
+ tileShape.quadraticCurveTo(-h, h, -h, h - TILE_R);
226
+ tileShape.lineTo(-h, -h + TILE_R);
227
+ tileShape.quadraticCurveTo(-h, -h, -h + TILE_R, -h);
228
+ }
229
+ const tileGeometry = new THREE.ExtrudeGeometry(tileShape, {
230
+ depth: TILE_DEPTH,
231
+ bevelEnabled: false,
232
+ curveSegments: 6,
233
+ });
234
+ // Extrude UVs come out in shape units, not 0..1 — remap the caps to span
235
+ // the texture, and the side wall so v runs bottom→top (ink rim at the top).
236
+ {
237
+ const pos = tileGeometry.attributes.position;
238
+ const uv = tileGeometry.attributes.uv;
239
+ const [caps, walls] = tileGeometry.groups;
240
+ for (let i = caps.start; i < caps.start + caps.count; i++) {
241
+ uv.setXY(i, pos.getX(i) / TILE_SIZE + 0.5, pos.getY(i) / TILE_SIZE + 0.5);
242
+ }
243
+ for (let i = walls.start; i < walls.start + walls.count; i++) {
244
+ uv.setXY(i, uv.getX(i) / TILE_SIZE + 0.5, pos.getZ(i) / TILE_DEPTH);
245
+ }
246
+ }
247
+ // Shape lies in XY, extruded toward +z — stand it up so the cap faces +y
248
+ // with the top surface at local y=+TILE_DEPTH/2 (mesh y stays -TILE_DEPTH/2).
249
+ tileGeometry.rotateX(-Math.PI / 2);
250
+ tileGeometry.translate(0, -TILE_DEPTH / 2, 0);
251
+
252
+ // One shared side texture/material for all 16 keycaps.
253
+ const sideCanvas = document.createElement("canvas");
254
+ sideCanvas.width = SIDE_PX_W * TEX_SCALE;
255
+ sideCanvas.height = SIDE_PX_H * TEX_SCALE;
256
+ const sideCtx = sideCanvas.getContext("2d");
257
+ drawTileSideCanvas(sideCtx);
258
+ const sideTexture = new THREE.CanvasTexture(sideCanvas);
259
+ sideTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
260
+ sideTexture.colorSpace = THREE.SRGBColorSpace;
261
+ const sideMaterial = new THREE.MeshBasicMaterial({ map: sideTexture });
262
+
263
  const tiles = []; // { mesh, ctx, texture, word, row, col }
264
  for (let r = 0; r < ROWS; r++) {
265
  for (let c = 0; c < COLS; c++) {
266
  const word = level.grid[r][c];
267
  const canvas = document.createElement("canvas");
268
+ canvas.width = canvas.height = TILE_PX * TEX_SCALE;
269
  const ctx = canvas.getContext("2d");
270
  drawTileCanvas(ctx, word);
271
 
 
273
  texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
274
  texture.colorSpace = THREE.SRGBColorSpace;
275
 
276
+ // ExtrudeGeometry groups: [0] = caps (top + hidden bottom), [1] = side wall
277
+ const mesh = new THREE.Mesh(tileGeometry, [
278
+ new THREE.MeshBasicMaterial({ map: texture, transparent: false }),
279
+ sideMaterial,
280
+ ]);
281
  const { x, z } = tileAt(r, c);
282
+ mesh.position.set(x, -TILE_DEPTH / 2, z); // top face flush with y=0
283
  board.add(mesh);
284
  tiles.push({ mesh, ctx, texture, word, row: r, col: c });
285
  }
286
  }
287
  const tile = (r, c) => tiles[r * COLS + c];
288
 
289
+ // Depress a keycap and let it spring back, like the doodle typed it.
290
+ function pressTile(t) {
291
+ gsap.timeline()
292
+ .to(t.mesh.position, { y: -TILE_DEPTH / 2 - 0.14, duration: 0.07, ease: "power2.out" })
293
+ .to(t.mesh.position, { y: -TILE_DEPTH / 2, duration: 0.3, ease: "back.out(2.2)" });
294
+ }
295
+
296
  // ---- character: a line-doodle on a billboarded plane ----
297
 
298
  function wobblyCircle(ctx, cx, cy, r, jitter) {
 
318
  const CHAR_PX_W = 256;
319
  const CHAR_PX_H = 320;
320
 
321
+ // One outlined "tube" limb: a jittered quadratic stroked twice — fat ink,
322
+ // then a thin paper core — so arms/legs read as drawn shapes, not sticks.
323
+ function limb(ctx, x0, y0, cx, cy, x1, y1) {
324
+ const J = 2.5;
325
+ ctx.beginPath();
326
+ ctx.moveTo(x0 + rand(-J, J), y0 + rand(-J, J));
327
+ ctx.quadraticCurveTo(cx + rand(-J, J), cy + rand(-J, J), x1 + rand(-J, J), y1 + rand(-J, J));
328
+ ctx.strokeStyle = INK;
329
+ ctx.lineWidth = 16;
330
+ ctx.stroke();
331
+ ctx.strokeStyle = "#fffdf7";
332
+ ctx.lineWidth = 9;
333
+ ctx.stroke();
334
+ ctx.strokeStyle = INK;
335
+ }
336
+
337
+ // Flat doodle shoe: a small ink-filled ellipse at the end of a leg.
338
+ function charFoot(ctx, x, y) {
339
+ ctx.beginPath();
340
+ ctx.ellipse(x + rand(-2, 2), y + rand(-1.5, 1.5), 9, 5, 0, 0, Math.PI * 2);
341
+ ctx.fillStyle = INK;
342
+ ctx.fill();
343
+ }
344
+
345
+ // Mitten hand: a paper-filled wobbly circle with an ink outline.
346
+ function charHand(ctx, x, y) {
347
+ wobblyCircle(ctx, x, y, 9, 1.5);
348
+ ctx.fillStyle = "#fffdf7";
349
+ ctx.fill();
350
+ ctx.lineWidth = 5;
351
+ ctx.strokeStyle = INK;
352
+ ctx.stroke();
353
+ }
354
+
355
+ // Dazed X eye: two short crossed strokes.
356
+ function xEye(ctx, cx, cy) {
357
+ ctx.lineWidth = 4.5;
358
+ wobblyLine(ctx, cx - 7, cy - 7, cx + 7, cy + 7, 1); ctx.stroke();
359
+ wobblyLine(ctx, cx - 7, cy + 7, cx + 7, cy - 7, 1); ctx.stroke();
360
+ }
361
+
362
  function drawCharCanvas(ctx, pose) {
363
  const J = 2.5;
364
+ ctx.setTransform(TEX_SCALE, 0, 0, TEX_SCALE, 0, 0);
365
  ctx.clearRect(0, 0, CHAR_PX_W, CHAR_PX_H);
366
  ctx.lineJoin = "round";
367
  ctx.lineCap = "round";
 
369
  ctx.fillStyle = INK;
370
  ctx.lineWidth = 7;
371
 
372
+ // limbs first, so the torso fill covers the shoulder/hip joins.
373
+ // shoulders ~(104,142)/(152,142), hips ~(112,205)/(144,205), feet base y≈308
374
+ if (pose === "think") {
375
+ limb(ctx, 104, 142, 88, 180, 86, 210); // arm down
376
+ limb(ctx, 152, 142, 178, 138, 146, 118); // hand up to the chin
377
+ limb(ctx, 112, 205, 106, 255, 104, 300); // legs straight
378
+ limb(ctx, 144, 205, 150, 255, 152, 300);
379
+ charFoot(ctx, 102, 303); charFoot(ctx, 154, 303);
380
+ charHand(ctx, 86, 210); charHand(ctx, 146, 118);
381
+ } else if (pose === "hop-up") {
382
+ // launch crouch: knees bent, feet still low, arms swung back
383
+ limb(ctx, 104, 145, 80, 170, 70, 212);
384
+ limb(ctx, 152, 145, 176, 170, 186, 212);
385
+ limb(ctx, 112, 205, 86, 252, 104, 294);
386
+ limb(ctx, 144, 205, 170, 252, 152, 294);
387
+ charFoot(ctx, 102, 298); charFoot(ctx, 154, 298);
388
+ charHand(ctx, 70, 212); charHand(ctx, 186, 212);
389
+ } else if (pose === "hop-mid") {
390
+ // peak tuck: knees out, feet pulled up, arms in a V overhead
391
+ limb(ctx, 104, 142, 80, 92, 70, 46);
392
+ limb(ctx, 152, 142, 176, 92, 186, 46);
393
+ limb(ctx, 112, 205, 82, 232, 108, 252);
394
+ limb(ctx, 144, 205, 174, 232, 148, 252);
395
+ charFoot(ctx, 106, 254); charFoot(ctx, 150, 254);
396
+ charHand(ctx, 70, 46); charHand(ctx, 186, 46);
397
+ } else if (pose === "hop-land") {
398
+ // deep landing bend: feet wide, arms thrown forward at chest height
399
+ limb(ctx, 104, 148, 80, 152, 58, 150);
400
+ limb(ctx, 152, 148, 176, 152, 198, 150);
401
+ limb(ctx, 112, 208, 82, 256, 88, 300);
402
+ limb(ctx, 144, 208, 174, 256, 168, 300);
403
+ charFoot(ctx, 86, 303); charFoot(ctx, 170, 303);
404
+ charHand(ctx, 58, 150); charHand(ctx, 198, 150);
405
+ } else if (pose === "dead-dizzy") {
406
+ // dazed: arms dangle straight down, legs slightly crossed
407
+ limb(ctx, 104, 145, 100, 182, 98, 218);
408
+ limb(ctx, 152, 145, 156, 182, 158, 218);
409
+ limb(ctx, 112, 205, 130, 255, 140, 300);
410
+ limb(ctx, 144, 205, 124, 255, 116, 300);
411
+ charFoot(ctx, 140, 303); charFoot(ctx, 114, 303);
412
+ charHand(ctx, 98, 218); charHand(ctx, 158, 218);
413
+ } else if (pose === "dead") {
414
+ // crumpled sprawl: everything flung wide and low
415
+ limb(ctx, 104, 150, 70, 158, 40, 176);
416
+ limb(ctx, 152, 150, 186, 158, 216, 176);
417
+ limb(ctx, 112, 208, 76, 252, 44, 290);
418
+ limb(ctx, 144, 208, 180, 252, 212, 290);
419
+ charFoot(ctx, 42, 293); charFoot(ctx, 214, 293);
420
+ charHand(ctx, 40, 176); charHand(ctx, 216, 176);
421
+ } else {
422
+ // idle: arms down-out, legs straight
423
+ limb(ctx, 104, 142, 90, 178, 82, 206);
424
+ limb(ctx, 152, 142, 166, 178, 174, 206);
425
+ limb(ctx, 112, 205, 106, 255, 104, 300);
426
+ limb(ctx, 144, 205, 150, 255, 152, 300);
427
+ charFoot(ctx, 102, 303); charFoot(ctx, 154, 303);
428
+ charHand(ctx, 82, 206); charHand(ctx, 174, 206);
429
+ }
430
+
431
+ // torso: a paper-filled bean drawn over the limb joins
432
+ wobblyRoundRect(ctx, 96, 118, 64, 92, 30, J);
433
+ ctx.fillStyle = "rgba(255,253,247,0.95)";
434
+ ctx.fill();
435
+ ctx.strokeStyle = INK;
436
+ ctx.lineWidth = 7;
437
  ctx.stroke();
438
+
439
+ // head, after the torso so it overlaps the neck
440
+ wobblyCircle(ctx, 128, 78, 44, 2);
441
+ ctx.fillStyle = "rgba(255,253,247,0.95)";
442
  ctx.fill();
443
+ ctx.stroke();
444
+
445
+ // one flat accent detail: a little scarf at the neck
446
+ ctx.strokeStyle = "#b3402e";
447
+ ctx.lineWidth = 8;
448
+ wobblyLine(ctx, 106, 126, 150, 126, 2); ctx.stroke();
449
+ ctx.strokeStyle = INK;
450
  ctx.fillStyle = INK;
451
 
452
+ // eyes
453
+ if (pose === "dead" || pose === "dead-dizzy") {
454
+ xEye(ctx, 110, 74); xEye(ctx, 146, 74);
455
+ } else if (pose === "idle" && Math.random() < 0.18) {
456
+ ctx.lineWidth = 5; // blink
457
+ wobblyLine(ctx, 102, 74, 118, 74, 1); ctx.stroke();
458
+ wobblyLine(ctx, 138, 74, 154, 74, 1); ctx.stroke();
459
  } else {
460
+ ctx.beginPath(); ctx.arc(110, 74, 5.5, 0, Math.PI * 2); ctx.fill();
461
+ ctx.beginPath(); ctx.arc(146, 74, 5.5, 0, Math.PI * 2); ctx.fill();
462
  }
463
+
464
+ // mouth
465
+ ctx.lineWidth = 6;
466
+ if (pose === "think" || pose === "hop-up") {
467
+ ctx.beginPath();
468
+ ctx.arc(128, 100, pose === "think" ? 9 : 7, 0, Math.PI * 2); // little "o"
469
  ctx.stroke();
470
+ } else if (pose === "hop-mid") {
471
+ ctx.beginPath();
472
+ ctx.arc(128, 96, 16, 0, Math.PI); // big open grin
473
+ ctx.closePath();
474
+ ctx.fill();
475
+ } else if (pose === "hop-land") {
476
+ wobblyLine(ctx, 108, 102, 148, 102, 1.5); ctx.stroke(); // gritted teeth
477
+ } else if (pose === "dead-dizzy") {
478
+ ctx.beginPath(); // wavy dazed squiggle
479
+ for (let i = 0; i <= 16; i++) {
480
+ const x = 108 + (i / 16) * 40;
481
+ const y = 102 + Math.sin((i / 16) * Math.PI * 3) * 4 + rand(-1, 1);
482
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
483
+ }
484
+ ctx.stroke();
485
+ } else if (pose === "dead") {
486
+ wobblyLine(ctx, 112, 100, 144, 100, 1.5); ctx.stroke();
487
+ ctx.beginPath(); // little tongue blob
488
+ ctx.ellipse(136 + rand(-1, 1), 110, 6, 9, 0, 0, Math.PI * 2);
489
+ ctx.fill();
490
  } else {
491
+ ctx.beginPath();
492
+ ctx.arc(128, 88, 20, Math.PI * 0.18, Math.PI * 0.82); // smile
493
  ctx.stroke();
494
  }
495
 
496
+ // pose extras
497
+ if (pose === "think") {
 
 
 
 
 
 
 
 
 
 
 
 
498
  ctx.font = '400 56px "Patrick Hand"';
499
  ctx.textAlign = "center";
500
  ctx.fillText("?", 196 + rand(-2, 2), 56 + rand(-2, 2));
501
+ } else if (pose === "hop-mid") {
502
+ ctx.lineWidth = 5; // motion dashes below the feet
503
+ wobblyLine(ctx, 106, 272, 100, 292, 1.5); ctx.stroke();
504
+ wobblyLine(ctx, 128, 276, 128, 298, 1.5); ctx.stroke();
505
+ wobblyLine(ctx, 150, 272, 156, 292, 1.5); ctx.stroke();
506
+ } else if (pose === "dead") {
507
+ ctx.lineWidth = 4; // dizzy spiral doodle above the head
508
+ ctx.beginPath();
509
+ for (let i = 0; i <= 40; i++) {
510
+ const a = (i / 40) * Math.PI * 4;
511
+ const r = 2 + (i / 40) * 11;
512
+ const x = 188 + r * Math.cos(a) + rand(-1, 1);
513
+ const y = 34 + r * Math.sin(a) + rand(-1, 1);
514
+ i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
515
+ }
516
+ ctx.stroke();
517
  }
518
  }
519
 
520
  const charCanvas = document.createElement("canvas");
521
+ charCanvas.width = CHAR_PX_W * TEX_SCALE;
522
+ charCanvas.height = CHAR_PX_H * TEX_SCALE;
523
  const charCtx = charCanvas.getContext("2d");
524
  let charPose = "idle";
525
  drawCharCanvas(charCtx, charPose);
526
 
527
  const charTexture = new THREE.CanvasTexture(charCanvas);
528
  charTexture.colorSpace = THREE.SRGBColorSpace;
529
+ const CHAR_STANDOFF = 0.12; // feet hover just above the keycap tops
530
+ const CHAR_Z_OFF = -0.15; // stand toward the tile's top edge, not on the word
531
+ const charGeometry = new THREE.PlaneGeometry(0.86, 1.075);
532
+ charGeometry.translate(0, 1.075 / 2, 0); // pivot at the feet
533
  const charMesh = new THREE.Mesh(
534
+ charGeometry,
535
  new THREE.MeshBasicMaterial({ map: charTexture, transparent: true })
536
  );
537
+ charMesh.renderOrder = 1; // only transparent mesh left — draw after keycaps
538
+ const charTilt = { z: 0 }; // cartoon lean, composed into the billboard quat
539
  const charPosFor = (r, c) => {
540
  const { x, z } = tileAt(r, c);
541
  return { x, z: z + CHAR_Z_OFF };
542
  };
543
+ // charGroup carries the grid position; charMesh.position.y is the jump arc.
544
  const startPos = charPosFor(level.start[0], level.start[1]);
545
+ const charGroup = new THREE.Group();
546
+ charGroup.position.set(startPos.x, CHAR_STANDOFF, startPos.z);
547
+ charGroup.add(charMesh);
548
+ board.add(charGroup);
549
 
550
  function setPose(pose) {
551
  charPose = pose;
 
560
  drawTileCanvas(t.ctx, t.word);
561
  t.texture.needsUpdate = true;
562
  }
563
+ drawTileSideCanvas(sideCtx);
564
+ sideTexture.needsUpdate = true;
565
  drawCharCanvas(charCtx, charPose);
566
  charTexture.needsUpdate = true;
567
  }, 340);
 
570
 
571
  const chipsEl = element.querySelector(".sq-chips");
572
  const hintEl = element.querySelector(".sq-hint");
 
573
 
574
+ let state = "idle"; // idle | hopping | judging | verdict | dead
575
  let pos = [...level.start];
576
  let words = []; // appended (non-structural) words
577
  let used = 0; // hops spent
 
587
 
588
  function bonk() {
589
  gsap.timeline()
590
+ .to(charTilt, { z: -0.13, duration: 0.05 })
591
+ .to(charTilt, { z: 0.13, duration: 0.09, repeat: 2, yoyo: true })
592
+ .to(charTilt, { z: 0, duration: 0.05 });
593
  }
594
 
595
  function hopTo(r, c) {
596
  state = "hopping";
597
  const { x, z } = charPosFor(r, c);
 
598
  gsap.timeline({ onComplete: () => land(r, c) })
599
+ .add(() => setPose("hop-up"))
600
+ .to(charMesh.scale, { x: 1.18, y: 0.78, duration: 0.08, ease: "power1.in" }) // anticipation squash (feet pivot)
601
+ .to(charMesh.scale, { x: 0.94, y: 1.12, duration: 0.09 })
602
+ .to(charGroup.position, { x, z, duration: 0.24, ease: "power3.out" }, 0.08) // fast-out horizontal
603
+ .to(charMesh.position, { y: 0.85, duration: 0.13, ease: "power2.out" }, 0.08) // arc up
604
+ .add(() => setPose("hop-mid"), 0.14)
605
+ .to(charMesh.position, { y: 0, duration: 0.12, ease: "power2.in" }, 0.21) // arc down
606
+ .add(() => setPose("hop-land"), 0.30)
607
+ .to(charMesh.scale, { x: 1.12, y: 0.84, duration: 0.06 }, 0.33) // landing squash
608
+ .to(charMesh.scale, { x: 1, y: 1, duration: 0.11, ease: "back.out(3)" }, 0.39);
609
  }
610
 
611
  function land(r, c) {
612
  pos = [r, c];
613
  used += 1;
 
 
614
 
615
+ // landed keycap press
616
  const t = tile(r, c);
617
+ pressTile(t);
 
 
 
618
  const word = t.word;
619
+
620
+ // the hop that EXCEEDS the window kills — even onto <eos>. The fatal
621
+ // word never makes it into the context: no chip, no slot.
622
+ if (used > level.budget) return die();
623
+
624
+ hud.update(used, word);
625
+ setPose("idle");
626
  if (word !== "<bos>" && word !== "<eos>") {
627
  words.push(word);
628
  addChip(word);
629
  }
630
 
631
+ if (word === "<eos>") return submit();
632
+ if (used === level.budget) {
633
+ hud.warnFull();
634
+ hintEl.textContent = "context window full! one more hop and…";
635
+ gsap.to(hintEl, { opacity: 1, duration: 0.3 });
636
+ }
637
  state = "idle";
638
  if (buffered) {
639
  const dir = buffered;
 
642
  }
643
  }
644
 
645
+ // Overflow death: a dizzy wobble, then the doodle crumples and slides
646
+ // clean off the bottom of the board.
647
+ function die() {
648
+ state = "dead";
649
+ buffered = null;
650
+ gsap.killTweensOf([charMesh.scale, charMesh.position, charGroup.position, charTilt]);
651
+ hud.overflow();
652
+ hintEl.textContent = "";
653
+ setPose("dead-dizzy");
654
+ gsap.timeline({ onComplete: showDeathCard })
655
+ .to(charTilt, { z: 0.18, duration: 0.11, yoyo: true, repeat: 3 }) // dizzy wobble
656
+ .add(() => setPose("dead"), 0.5)
657
+ .to(charMesh.scale, { y: 0.6, x: 1.25, duration: 0.15, ease: "power2.in" }, 0.5) // crumple (feet pivot)
658
+ .to(charMesh.position, { y: "-=3", duration: 0.6, ease: "power1.in" }, 0.85) // slides off the board
659
+ .to(charTilt, { z: Math.PI * 0.85, duration: 0.6, ease: "power1.in" }, 0.85)
660
+ .to(charMesh.material, { opacity: 0, duration: 0.25 }, 1.2);
661
+ }
662
+
663
  // ---- judging + verdict ----
664
 
665
  const overlayEl = element.querySelector(".sq-overlay");
 
670
  const retryBtn = element.querySelector(".sq-retry");
671
  const againBtn = element.querySelector(".sq-again");
672
 
673
+ async function submit() {
674
  state = "judging";
675
  buffered = null;
676
  setPose("think");
677
+ hintEl.textContent = "the judge is reading…";
 
 
678
  gsap.to(hintEl, { opacity: 1, duration: 0.3 });
679
  const bob = gsap.to(charMesh.position, {
680
+ y: 0.12, duration: 0.55, repeat: -1, yoyo: true, ease: "sine.inOut",
681
  });
682
  const slowTimer = setTimeout(() => {
683
  hintEl.textContent = "the tiny model is stretching its parameters…";
 
691
  }
692
  clearTimeout(slowTimer);
693
  bob.kill();
694
+ gsap.to(charMesh.position, { y: 0, duration: 0.2 });
695
 
696
+ if (!res || !res.ok) return judgeFailed(res && res.error);
697
+ showVerdict(res);
698
  }
699
 
700
  function openCard() {
 
707
  );
708
  }
709
 
710
+ function judgeFailed(error) {
711
  console.warn("judge failed:", error);
712
  sentenceEl.textContent = "the judge dozed off…";
713
  barsEl.innerHTML = "";
 
716
  againBtn.classList.add("sq-hidden");
717
  retryBtn.onclick = () => {
718
  overlayEl.classList.add("sq-hidden");
719
+ submit();
720
  };
721
  openCard();
722
  }
723
 
724
+ // Death card: same overlay, but there's no sentence to judge — the
725
+ // context window already ate the doodle.
726
+ function showDeathCard() {
727
+ sentenceEl.textContent = "the context window overflowed…";
728
+ barsEl.innerHTML = "";
729
+ stampEl.textContent = "context exceeded!";
730
+ stampEl.classList.remove("sq-win");
731
+ stampEl.style.opacity = 0;
732
+ retryBtn.classList.add("sq-hidden");
733
+ againBtn.classList.remove("sq-hidden");
734
+ openCard();
735
+ gsap.fromTo(
736
+ stampEl,
737
+ { opacity: 0, scale: 3, rotation: 24 },
738
+ { opacity: 1, scale: 1, rotation: 8, duration: 0.3, ease: "power3.in", delay: 0.45 }
739
+ );
740
+ }
741
+
742
+ function showVerdict(res) {
743
  state = "verdict";
744
+ setPose(res.verdict === "win" ? "hop-mid" : "idle");
745
  hintEl.textContent = "";
746
 
747
  sentenceEl.innerHTML = "";
748
  const quote = document.createElement("div");
749
  quote.textContent = `“${res.sentence}”`;
750
  sentenceEl.appendChild(quote);
 
 
 
 
 
 
751
 
752
  // probability bars, sorted by prob so the verdict reads top-down
753
  barsEl.innerHTML = "";
 
812
  used = 0;
813
  pos = [...level.start];
814
  [...chipsEl.querySelectorAll(".sq-chip:not(.sq-chip-bos)")].forEach((c) => c.remove());
815
+ hud.reset();
816
  hintEl.textContent = "arrow keys / WASD to hop";
817
  gsap.to(hintEl, { opacity: 1, duration: 0.4 });
818
 
 
821
  gsap.timeline({ onComplete: () => (state = "idle") })
822
  .to(charMesh.scale, { x: 0, y: 0, duration: 0.18, ease: "power2.in" })
823
  .add(() => {
824
+ charGroup.position.set(home.x, CHAR_STANDOFF, home.z);
825
+ charMesh.position.y = 0;
826
+ charTilt.z = 0;
827
+ charMesh.material.opacity = 1;
828
  setPose("idle");
829
  })
830
  .to(charMesh.scale, { x: 1, y: 1, duration: 0.28, ease: "back.out(2.5)" });
 
867
  const w = stage.clientWidth || 1;
868
  const h = stage.clientHeight || 1;
869
  const aspect = w / h;
870
+ // fov stays 32°; dolly the camera so the board fits either way
871
+ const need = Math.max(VIEW, 6.8 / aspect); // keep the board inside narrow windows
872
+ camera.position.z = need / 2 / Math.tan(THREE.MathUtils.degToRad(32 / 2));
873
+ camera.aspect = aspect;
874
+ camera.lookAt(0, 0.25, 0);
875
  camera.updateProjectionMatrix();
876
  renderer.setSize(w, h);
877
  }
878
  new ResizeObserver(resize).observe(stage);
879
  resize();
880
 
881
+ // The board never moves, so the billboard's parent correction is constant.
882
+ const _bbParentInv = new THREE.Quaternion();
883
+ charGroup.getWorldQuaternion(_bbParentInv).invert();
884
+ const _bbSpin = new THREE.Quaternion();
885
+ const _Z = new THREE.Vector3(0, 0, 1);
886
+
887
  renderer.setAnimationLoop(() => {
888
+ // billboard the doodle, then lean it by charTilt.z (bonks, deaths…)
889
+ charMesh.quaternion
890
+ .copy(_bbParentInv)
891
+ .multiply(camera.quaternion)
892
+ .multiply(_bbSpin.setFromAxisAngle(_Z, charTilt.z));
893
  renderer.render(scene, camera);
894
  });
895
 
static/style.css CHANGED
@@ -72,24 +72,56 @@
72
  transform: rotate(-1.5deg);
73
  }
74
 
75
- .sq-pips {
 
76
  display: flex;
77
- gap: 7px;
78
  align-items: center;
79
- font-size: 20px;
80
  }
81
- .sq-pip {
82
- width: 11px;
83
- height: 20px;
84
- border: 2px solid var(--ink);
85
- border-radius: 65% 35% 55% 45% / 40% 60% 40% 60%;
86
- transform: rotate(var(--tilt, 2deg));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
88
- .sq-pip.sq-used {
 
 
 
 
 
 
 
89
  background:
90
  linear-gradient(45deg, transparent 43%, var(--accent) 43% 57%, transparent 57%),
91
  linear-gradient(-45deg, transparent 43%, var(--accent) 43% 57%, transparent 57%);
92
- border-color: var(--ink-soft);
93
  }
94
 
95
  /* ---- prompt strip ---- */
 
72
  transform: rotate(-1.5deg);
73
  }
74
 
75
+ /* context window: a labelled counter + ten little token slots that fill up */
76
+ .sq-context {
77
  display: flex;
 
78
  align-items: center;
79
+ gap: 10px;
80
  }
81
+ .sq-context-label {
82
+ font-family: "Caveat", cursive;
83
+ font-size: 25px;
84
+ font-weight: 600;
85
+ color: var(--ink-soft);
86
+ }
87
+ .sq-context-count {
88
+ font-family: "Caveat", cursive;
89
+ font-size: 30px;
90
+ font-weight: 700;
91
+ min-width: 52px;
92
+ }
93
+ .sq-context-count.sq-full {
94
+ color: var(--accent);
95
+ }
96
+ .sq-slots {
97
+ display: flex;
98
+ gap: 5px;
99
+ }
100
+ .sq-slot {
101
+ min-width: 30px;
102
+ height: 26px;
103
+ padding: 0 4px;
104
+ display: grid;
105
+ place-items: center;
106
+ font-size: 13px;
107
+ line-height: 1;
108
+ color: var(--ink);
109
+ border: 2px dashed var(--ink-soft);
110
+ border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;
111
+ transform: rotate(var(--tilt, 0deg));
112
+ background: rgba(255, 255, 255, 0.35);
113
  }
114
+ .sq-slot.sq-filled {
115
+ border-style: solid;
116
+ border-color: var(--ink);
117
+ background: rgba(255, 255, 255, 0.8);
118
+ }
119
+ .sq-slot.sq-burst {
120
+ border-color: var(--accent);
121
+ color: var(--accent);
122
  background:
123
  linear-gradient(45deg, transparent 43%, var(--accent) 43% 57%, transparent 57%),
124
  linear-gradient(-45deg, transparent 43%, var(--accent) 43% 57%, transparent 57%);
 
125
  }
126
 
127
  /* ---- prompt strip ---- */