<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

 <title>Catatan Ashura</title>
 <link href="https://ashura.id/atom.xml" rel="self"/>
 <link href="https://ashura.id/"/>
 <updated>2026-10-30T12:16:23+00:00</updated>
 <id>https://ashura.id</id>
 <author>
   <name>Asif Hummam Rais</name>
   <email>hashshura@gmail.com</email>
 </author>

 
 <entry>
   <title>Wordfall</title>
   <link href="https://ashura.id/wordfall"/>
   <updated>2026-08-13T00:00:00+00:00</updated>
   <id>https://ashura.id/wordfall</id>
   <content type="html">&lt;style&gt;
#wf-stage{position:relative;left:50%;transform:translateX(-50%);width:calc(100vw - 8px);max-width:560px;margin:0 0 6px;}
#wf-wrap{position:relative;}

/* These panels sit in normal flow rather than being absolutely positioned
   inside a fixed-height wrapper: overlaying them on a box shorter than their
   own content is what forced an inner scrollbar and made the bottom of the
   menu (the room list especially) hard to reach on a phone. In flow, the
   panel is simply as tall as it needs to be and the page never scrolls
   inside itself. */
#wf-menu,#wf-lobby{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:11px;background:#fbfbf7;border:2px solid #222;border-radius:8px;font-family:inherit;padding:22px 10px 18px;}
#wf-menu[hidden],#wf-lobby[hidden]{display:none;}
#wf-menu h2,#wf-lobby h2{margin:0;font-size:clamp(19px,5.5vw,28px);text-align:center;}
#wf-menu p{margin:0;font-size:13px;color:#777;text-align:center;max-width:34em;padding:0 10px;}
/* The same colours the words are drawn in on the board, so the key reads as a
   key rather than as a paragraph nobody finishes. */
.c-move{color:#2980b9;}
.c-atk{color:#c0392b;}
.c-spec{color:#222;}
#wf-menu p#wf-tag{font-size:15px;font-weight:bold;color:#222;margin:0;}
#wf-menu p#wf-tag2{font-size:11.5px;color:#aaa;margin:0;}
#wf-key3{display:flex;flex-direction:column;gap:5px;}
#wf-key3 div{display:flex;align-items:baseline;gap:9px;}
#wf-key3 b{font-family:monospace;font-size:19px;font-weight:bold;min-width:3.6em;text-align:right;}
#wf-key3 span{font-size:12.5px;color:#888;}
#wf-menu .row,#wf-lobby .row{display:flex;gap:9px;flex-wrap:wrap;justify-content:center;padding:0 8px;}
#wf-menu input{font:inherit;font-size:16px;padding:11px 13px;border:1.5px solid #ccc;border-radius:9px;background:#fff;min-width:140px;}
#wf-menu button,#wf-lobby button{font:inherit;font-size:16px;font-weight:bold;padding:13px 20px;border:2px solid #222;border-radius:10px;background:#222;color:#fbfbf7;cursor:pointer;}
#wf-menu button.ghost,#wf-lobby button.ghost{background:#fbfbf7;color:#222;}
#wf-menu button:disabled{opacity:.4;cursor:not-allowed;}
#wf-classes{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;width:min(300px,80vw);}
#wf-clsinfo{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;width:min(320px,88vw);}
#wf-clsinfo .box{display:flex;flex-direction:column;align-items:center;gap:1px;padding:8px 4px;
  border:1.5px solid #ddd;border-radius:9px;background:#fff;}
#wf-clsinfo .ico{font-size:17px;line-height:1.1;}
#wf-clsinfo .k{font-size:9px;font-weight:bold;letter-spacing:.06em;text-transform:uppercase;color:#aaa;}
#wf-clsinfo .v{font-size:12px;font-weight:bold;color:#222;font-family:monospace;}
.wf-cls{font:inherit;font-size:13px;font-weight:bold;padding:10px 12px;border:2.5px solid #ddd;border-radius:10px;background:#fff;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:2px;min-width:64px;opacity:.75;}
.wf-cls .e{font-size:22px;}
.wf-cls.sel{border-color:#c0392b;background:#fff6f4;opacity:1;box-shadow:0 0 0 2px rgba(192,57,43,.15);}
.wf-cls.sel::after{content:&apos;✓ selected&apos;;font-size:10px;font-weight:bold;color:#c0392b;margin-top:1px;}
#wf-cls-desc{font-size:12.5px;color:#777;text-align:center;min-height:2.6em;max-width:30em;padding:0 10px;margin:0;}
/* Needs the #wf-menu prefix to outrank the generic `#wf-menu p` above. */
#wf-lobby p#wf-lobby-tip{margin:0;font-size:12.5px;color:#777;text-align:center;max-width:32em;padding:0 10px;}
#wf-menu p.wf-label,#wf-lobby p.wf-label{margin:12px 0 0;font-size:11px;font-weight:bold;letter-spacing:.05em;
  text-transform:uppercase;color:#aaa;}
#wf-sizes{display:flex;gap:6px;flex-wrap:wrap;justify-content:center;}
.wf-size{font:inherit;font-size:12px;font-weight:bold;padding:7px 10px;border:2px solid #ddd;border-radius:8px;background:#fff;cursor:pointer;opacity:.75;}
.wf-size.sel{border-color:#2980b9;background:#f4f9fd;opacity:1;}
#wf-rooms{width:min(440px,94%);max-height:min(30vh,200px);overflow:auto;-webkit-overflow-scrolling:touch;touch-action:pan-y;font-size:14px;}
#wf-rooms div{display:flex;justify-content:space-between;gap:8px;padding:11px;border:1px dashed #ccc;border-radius:8px;margin-bottom:6px;cursor:pointer;}
#wf-rooms div:active{background:#eee;}
#wf-note{font-size:12px;color:#999;text-align:center;padding:0 12px;}
#wf-topbtns{position:absolute;top:6px;right:6px;z-index:5;display:flex;gap:5px;align-items:flex-start;}
/* Faint until you reach for them: they sit over the health strip, so they
   should not compete with it for attention. */
#wf-exit,#wf-full{font:inherit;font-size:12px;font-weight:bold;padding:7px 10px;
  border:1.5px solid rgba(140,140,140,.35);border-radius:8px;background:rgba(251,251,247,.5);
  color:rgba(85,85,85,.7);cursor:pointer;backdrop-filter:blur(2px);}
#wf-exit:hover,#wf-full:hover,#wf-exit:active,#wf-full:active{
  background:rgba(251,251,247,.96);border-color:#bbb;color:#333;}
#wf-exit[hidden],#wf-full[hidden]{display:none;}
/* iOS Safari has no Element.requestFullscreen at all, so there is a fallback:
   pin the stage over the viewport ourselves. Visually the same result, and it
   means the button works on an iPhone instead of hiding itself. */
#wf-stage.immersive{position:fixed;inset:0;left:0;width:100vw;max-width:none;transform:none;
  z-index:9999;background:#fbfbf7;display:flex;flex-direction:column;justify-content:center;
  padding:4px;overflow:auto;-webkit-overflow-scrolling:touch;}
/* In fullscreen the stage is the whole screen, so let it fill and centre. */
#wf-stage:fullscreen{width:100vw;max-width:none;left:0;transform:none;display:flex;
  flex-direction:column;justify-content:center;background:#fbfbf7;padding:4px;overflow:hidden;}
#wf-stage:-webkit-full-screen{width:100vw;max-width:none;left:0;transform:none;background:#fbfbf7;}

#wf-roster{width:min(420px,94%);font-size:14px;}
#wf-roster .p{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px 11px;border:1.5px solid #ddd;border-radius:9px;margin-bottom:6px;background:#fbfbf7;}
#wf-roster .p.ready{border-color:#2e8b57;}
#wf-countdown{font-size:15px;font-weight:bold;color:#c0392b;min-height:1.3em;}
#wf-invite-url{width:min(420px,92%);font:inherit;font-size:12px;padding:8px 10px;border:1.5px solid #ccc;
  border-radius:8px;background:#fff;color:#555;text-align:center;}
#wf-invite-url[hidden],#wf-invite[hidden]{display:none;}

#wf-score{display:flex;flex-wrap:nowrap;align-items:center;gap:6px;height:32px;margin:0 2px 4px;padding:0 1px;font-size:12px;line-height:1;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;}
#wf-score::-webkit-scrollbar{display:none;}
#wf-score .who{display:flex;align-items:center;gap:5px;flex:0 0 auto;padding:5px 8px;border:1.5px solid #ddd;border-radius:8px;background:#fbfbf7;white-space:nowrap;}
#wf-score .who.self{border-color:#222;font-weight:bold;}
#wf-score .who.dead{opacity:.4;text-decoration:line-through;}
#wf-score .hp{font-variant-numeric:tabular-nums;color:#999;}

#wf-canvas{display:block;width:100%;height:auto;background:#fbfbf7;border:2px solid #222;border-radius:8px;touch-action:none;user-select:none;-webkit-user-select:none;}
/* An author `display` beats the browser&apos;s own `[hidden]{display:none}`, so
   every element here that sets one needs `hidden` spelled out or it can
   never actually hide. */
#wf-canvas[hidden],#wf-inputbar[hidden],#wf-keys[hidden],#wf-score[hidden]{display:none;}

#wf-inputbar{display:flex;gap:8px;margin:8px 2px 4px;}
#wf-typed{flex:1;font:inherit;font-size:18px;padding:12px 14px;border:2px solid #222;border-radius:10px;background:#fff;letter-spacing:.02em;caret-color:transparent;}
#wf-typed:disabled{opacity:.5;background:#f1f1e9;}
#wf-hint{font-size:12px;color:#777;text-align:center;margin:0 2px 10px;}

/* A custom keyboard rather than the OS one: fixed, small, predictable height,
   so the map above it never gets shoved off-screen or resized mid-match the
   way a real mobile keyboard would. The input stays `readonly` so tapping it
   never summons the real one either. */
/* Every row is exactly ten flex units wide, and the shorter rows spend the
   difference on invisible spacers: half a key each side of ASDF, and a key
   and a half on the left of ZXCV (mirroring the backspace that sits to the
   right of M). That is what produces a phone keyboard&apos;s staggered look —
   sizing keys individually and centring the rows does not, because the rows
   then either all stretch flush to the same width on a narrow screen or
   indent by unrelated amounts on a wide one. */
#wf-keys{display:flex;flex-direction:column;gap:5px;width:100%;max-width:430px;
  margin:0 auto 14px;touch-action:none;user-select:none;-webkit-user-select:none;}
#wf-keys.disabled{opacity:.4;pointer-events:none;}
#wf-keys .row{display:flex;gap:4px;}
.wf-key{flex:1 1 0;min-width:0;font:inherit;font-size:15px;font-weight:bold;padding:0;height:48px;
  border:1.5px solid #ccc;border-radius:7px;background:#fbfbf7;color:#222;cursor:pointer;touch-action:none;}
.wf-key:active,.wf-key.down{background:#222;color:#fbfbf7;}
.wf-key.half{flex:0.5 1 0;}
.wf-key.wide{flex:1.5 1 0;font-size:16px;}
.wf-key.pad{visibility:hidden;pointer-events:none;}
.wf-key.space{height:52px;font-size:15px;}
@media (max-width:400px){ .wf-key{height:44px;font-size:14px;} .wf-key.space{height:48px;} }
#wf-spectating{font-size:13px;font-weight:bold;color:#c0392b;text-align:center;margin:0 2px 8px;}
#wf-spectating[hidden]{display:none;}

#wf-cta{font-size:14px;line-height:1.6;color:#555;margin:0 0 26px;padding:14px 16px;border:1.5px dashed #ccc;border-radius:10px;background:#fbfbf7;}
#wf-cta b{color:#222;}
&lt;/style&gt;

&lt;div id=&quot;wf-stage&quot;&gt;
&lt;div id=&quot;wf-topbtns&quot;&gt;
  &lt;button id=&quot;wf-full&quot; type=&quot;button&quot;&gt;⛶ full&lt;/button&gt;
  &lt;div id=&quot;wf-exit&quot; hidden=&quot;&quot;&gt;Leave&lt;/div&gt;
&lt;/div&gt;
&lt;div id=&quot;wf-wrap&quot;&gt;

&lt;div id=&quot;wf-menu&quot;&gt;
  &lt;h2&gt;Wordfall&lt;/h2&gt;
  &lt;p id=&quot;wf-tag&quot;&gt;Nine words on screen. Type one, it happens.&lt;/p&gt;
  &lt;div id=&quot;wf-key3&quot;&gt;
    &lt;div&gt;&lt;b class=&quot;c-move&quot;&gt;jog&lt;/b&gt;&lt;span&gt;step one tile&lt;/span&gt;&lt;/div&gt;
    &lt;div&gt;&lt;b class=&quot;c-atk&quot;&gt;jab&lt;/b&gt;&lt;span&gt;swing that way&lt;/span&gt;&lt;/div&gt;
    &lt;div&gt;&lt;b class=&quot;c-spec&quot;&gt;fade&lt;/b&gt;&lt;span&gt;your special&lt;/span&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;p id=&quot;wf-tag2&quot;&gt;Used a word? It&apos;s replaced. The next two are already showing.&lt;/p&gt;
  &lt;p class=&quot;wf-label&quot;&gt;Map size&lt;/p&gt;
  &lt;div id=&quot;wf-sizes&quot;&gt;
    &lt;button class=&quot;wf-size&quot; data-size=&quot;small&quot;&gt;Small 8x8&lt;/button&gt;
    &lt;button class=&quot;wf-size sel&quot; data-size=&quot;medium&quot;&gt;Medium 12x12&lt;/button&gt;
    &lt;button class=&quot;wf-size&quot; data-size=&quot;big&quot;&gt;Big 16x16&lt;/button&gt;
  &lt;/div&gt;
  &lt;div class=&quot;row&quot;&gt;
    &lt;input id=&quot;wf-name&quot; maxlength=&quot;12&quot; placeholder=&quot;your name&quot; /&gt;
  &lt;/div&gt;
  &lt;div class=&quot;row&quot;&gt;
    &lt;button id=&quot;wf-create&quot;&gt;Create room&lt;/button&gt;
    &lt;button id=&quot;wf-refresh&quot; class=&quot;ghost&quot;&gt;Find rooms&lt;/button&gt;
    &lt;button id=&quot;wf-vsbot&quot; class=&quot;ghost&quot;&gt;Play vs Bot&lt;/button&gt;
  &lt;/div&gt;
  &lt;div id=&quot;wf-rooms&quot; hidden=&quot;&quot;&gt;&lt;/div&gt;
  &lt;div id=&quot;wf-note&quot;&gt;&lt;/div&gt;
&lt;/div&gt;

&lt;div id=&quot;wf-lobby&quot; hidden=&quot;&quot;&gt;
  &lt;h2 id=&quot;wf-lobby-title&quot;&gt;Room&lt;/h2&gt;
  &lt;p id=&quot;wf-lobby-tip&quot;&gt;Type a &lt;b class=&quot;c-move&quot;&gt;blue&lt;/b&gt; word to move, a &lt;b class=&quot;c-atk&quot;&gt;red&lt;/b&gt; one to
     attack — each is replaced the moment you use it. Walk over a green med kit to heal.&lt;/p&gt;
  &lt;div id=&quot;wf-roster&quot;&gt;&lt;/div&gt;
  &lt;p class=&quot;wf-label&quot;&gt;Your name&lt;/p&gt;
  &lt;div class=&quot;row&quot;&gt;
    &lt;input id=&quot;wf-lobby-name&quot; maxlength=&quot;12&quot; placeholder=&quot;your name&quot; /&gt;
  &lt;/div&gt;
  &lt;p class=&quot;wf-label&quot;&gt;Your class&lt;/p&gt;
  &lt;div id=&quot;wf-classes&quot;&gt;
    &lt;button class=&quot;wf-cls sel&quot; data-cls=&quot;rogue&quot;&gt;&lt;span class=&quot;e&quot;&gt;🥷&lt;/span&gt;Rogue&lt;/button&gt;
    &lt;button class=&quot;wf-cls&quot; data-cls=&quot;fighter&quot;&gt;&lt;span class=&quot;e&quot;&gt;🛡️&lt;/span&gt;Fighter&lt;/button&gt;
    &lt;button class=&quot;wf-cls&quot; data-cls=&quot;ranger&quot;&gt;&lt;span class=&quot;e&quot;&gt;🏹&lt;/span&gt;Ranger&lt;/button&gt;
    &lt;button class=&quot;wf-cls&quot; data-cls=&quot;mage&quot;&gt;&lt;span class=&quot;e&quot;&gt;🔮&lt;/span&gt;Mage&lt;/button&gt;
  &lt;/div&gt;
  &lt;div id=&quot;wf-clsinfo&quot;&gt;
    &lt;div class=&quot;box&quot;&gt;&lt;span class=&quot;ico&quot;&gt;⌨&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;typing&lt;/span&gt;&lt;span class=&quot;v&quot; id=&quot;wf-info-type&quot;&gt;—&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;box&quot;&gt;&lt;span class=&quot;ico&quot;&gt;⚔&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;attack&lt;/span&gt;&lt;span class=&quot;v&quot; id=&quot;wf-info-atk&quot;&gt;—&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;box&quot;&gt;&lt;span class=&quot;ico&quot;&gt;✦&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;special&lt;/span&gt;&lt;span class=&quot;v&quot; id=&quot;wf-info-spec&quot;&gt;—&lt;/span&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div id=&quot;wf-countdown&quot;&gt;&lt;/div&gt;
  &lt;div class=&quot;row&quot;&gt;
    &lt;button id=&quot;wf-ready&quot;&gt;Ready&lt;/button&gt;
    &lt;button id=&quot;wf-invite&quot; class=&quot;ghost&quot; hidden=&quot;&quot;&gt;Copy invite link&lt;/button&gt;
  &lt;/div&gt;
  &lt;input id=&quot;wf-invite-url&quot; hidden=&quot;&quot; readonly=&quot;&quot; /&gt;
&lt;/div&gt;

&lt;div id=&quot;wf-score&quot; hidden=&quot;&quot;&gt;&lt;/div&gt;
&lt;canvas id=&quot;wf-canvas&quot; width=&quot;480&quot; height=&quot;480&quot; hidden=&quot;&quot;&gt;&lt;/canvas&gt;
&lt;div id=&quot;wf-spectating&quot; hidden=&quot;&quot;&gt;You&apos;re out — spectating until one player is left.&lt;/div&gt;
&lt;div id=&quot;wf-inputbar&quot; hidden=&quot;&quot;&gt;
  &lt;input id=&quot;wf-typed&quot; readonly=&quot;&quot; autocomplete=&quot;off&quot; autocorrect=&quot;off&quot; autocapitalize=&quot;off&quot; spellcheck=&quot;false&quot; placeholder=&quot;type a word…&quot; /&gt;
&lt;/div&gt;
&lt;div id=&quot;wf-keys&quot; hidden=&quot;&quot;&gt;
  &lt;div class=&quot;row&quot; data-row=&quot;qwertyuiop&quot;&gt;&lt;/div&gt;
  &lt;div class=&quot;row&quot; data-row=&quot;asdfghjkl&quot;&gt;&lt;/div&gt;
  &lt;div class=&quot;row&quot; data-row=&quot;zxcvbnm&quot;&gt;&lt;/div&gt;
  &lt;div class=&quot;row&quot;&gt;&lt;button class=&quot;wf-key space&quot; data-key=&quot;space&quot; type=&quot;button&quot;&gt;space / submit&lt;/button&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div id=&quot;wf-hint&quot; hidden=&quot;&quot;&gt;Finishing a word runs it automatically — space or enter only when it&apos;s also the start of a longer one. The greyed words behind each are what comes next, so you can read ahead while typing. A wrong word just clears, no penalty.&lt;/div&gt;

&lt;/div&gt;
&lt;/div&gt;

&lt;p id=&quot;wf-cta&quot;&gt;&lt;b&gt;Play:&lt;/b&gt; open this post on two devices (or two tabs) and
create/join the same room, or hit Play vs Bot to go solo. Move words run along
the outside of the grid, attacks just inside them, and your special underneath —
each trailed by the next two queued for it, so reading ahead is how a short word
actually becomes fast.&lt;/p&gt;

&lt;script src=&quot;/assets/js/wordfall-sim.js&quot;&gt;&lt;/script&gt;

&lt;script&gt;
(function () {
  &apos;use strict&apos;;
  var S = window.WordfallSim;
  var COLORS = [&apos;#c0392b&apos;, &apos;#2e8b57&apos;, &apos;#2980b9&apos;, &apos;#8e44ad&apos;, &apos;#d35400&apos;, &apos;#16a085&apos;];
  var EMOJI = { rogue: &apos;🥷&apos;, fighter: &apos;🛡️&apos;, ranger: &apos;🏹&apos;, mage: &apos;🔮&apos; };
  var DIR_ANGLE = { right: 0, down: Math.PI / 2, left: Math.PI, up: -Math.PI / 2 };

  var NET_URL = &apos;https://wordfall.hashshura.workers.dev&apos;;
  try { NET_URL = localStorage.getItem(&apos;wf_server&apos;) || NET_URL; } catch (e) {}

  var stage = document.getElementById(&apos;wf-stage&apos;);
  var wrap = document.getElementById(&apos;wf-wrap&apos;);
  var menuEl = document.getElementById(&apos;wf-menu&apos;);
  var lobbyEl = document.getElementById(&apos;wf-lobby&apos;);
  var exitBtn = document.getElementById(&apos;wf-exit&apos;);
  var nameEl = document.getElementById(&apos;wf-name&apos;);
  var lobbyNameEl = document.getElementById(&apos;wf-lobby-name&apos;);
  var noteEl = document.getElementById(&apos;wf-note&apos;);
  var roomsEl = document.getElementById(&apos;wf-rooms&apos;);
  var createBtn = document.getElementById(&apos;wf-create&apos;);
  var refreshBtn = document.getElementById(&apos;wf-refresh&apos;);
  var classBtns = [].slice.call(document.querySelectorAll(&apos;.wf-cls&apos;));
  var rosterEl = document.getElementById(&apos;wf-roster&apos;);
  var countdownEl = document.getElementById(&apos;wf-countdown&apos;);
  var readyBtn = document.getElementById(&apos;wf-ready&apos;);
  var scoreEl = document.getElementById(&apos;wf-score&apos;);
  var canvas = document.getElementById(&apos;wf-canvas&apos;);
  var ctx = canvas.getContext(&apos;2d&apos;);
  var spectatingEl = document.getElementById(&apos;wf-spectating&apos;);
  var inputbarEl = document.getElementById(&apos;wf-inputbar&apos;);
  var typedEl = document.getElementById(&apos;wf-typed&apos;);
  var hintEl = document.getElementById(&apos;wf-hint&apos;);
  var keysEl = document.getElementById(&apos;wf-keys&apos;);

  try { nameEl.value = localStorage.getItem(&apos;wf_name&apos;) || &apos;&apos;; } catch (e) {}
  lobbyNameEl.value = nameEl.value;

  // Arriving through an invite link skips the menu, so the name box there was
  // never seen: the same edit has to be possible from inside the room.
  lobbyNameEl.addEventListener(&apos;change&apos;, function () {
    var name = (lobbyNameEl.value || &apos;you&apos;).slice(0, 12);
    lobbyNameEl.value = name;
    nameEl.value = name;
    try { localStorage.setItem(&apos;wf_name&apos;, name); } catch (e) {}
    if (botMode) renderBotLobby();
    else send({ type: &apos;setName&apos;, name: name });
  });

  // Read straight off the class definitions, so the three boxes can never claim
  // something the simulation does not actually do.
  // Width x length, read along the direction you attack. So the fighter&apos;s
  // sideways sweep is three wide and one deep, the ranger&apos;s shot is one wide and
  // three deep, and the mage&apos;s beam is one wide running off the map. These were
  // the wrong way round, which made the fighter and ranger look swapped.
  var SHAPE_LABEL = { point: &apos;1x1&apos;, wide3: &apos;3x1&apos;, line3: &apos;1x3&apos;, beamInf: &apos;1x\u221E&apos; };
  var SPECIAL_LABEL = { vanish: &apos;VANISH&apos;, shieldStun: &apos;STUN 5x5&apos;, trap: &apos;TRAP&apos;,
                        heal: &apos;HEAL&apos;, supercharge: &apos;SUPERCHARGE&apos; };
  var infoType = document.getElementById(&apos;wf-info-type&apos;);
  var infoAtk = document.getElementById(&apos;wf-info-atk&apos;);
  var infoSpec = document.getElementById(&apos;wf-info-spec&apos;);

  function typingLoad(cls) {
    var w = S.CLASSES[cls].words;
    var all = w.move.concat(w.attack, w.special);
    var avg = all.reduce(function (a, x) { return a + x.length; }, 0) / all.length;
    return avg &lt; 5 ? &apos;EASY&apos; : avg &lt; 6 ? &apos;MEDIUM&apos; : avg &lt; 7.5 ? &apos;TRICKY&apos; : &apos;HARD&apos;;
  }
  function renderClassInfo() {
    var def = S.CLASSES[myCls];
    infoType.textContent = typingLoad(myCls);
    infoAtk.textContent = SHAPE_LABEL[def.attackShape] || &apos;?&apos;;
    infoSpec.textContent = SPECIAL_LABEL[def.special.kind] || &apos;?&apos;;
  }

  var myCls = &apos;rogue&apos;;
  classBtns.forEach(function (b) {
    b.addEventListener(&apos;click&apos;, function () {
      myCls = b.getAttribute(&apos;data-cls&apos;);
      classBtns.forEach(function (x) { x.classList.toggle(&apos;sel&apos;, x === b); });
      renderClassInfo();
      // Changing class un-readies you, and everyone else sees the new pick.
      if (iAmReady) { iAmReady = false; readyBtn.textContent = &apos;Ready&apos;; }
      if (botMode) renderBotLobby();
      else send({ type: &apos;setClass&apos;, cls: myCls, ready: false });
    });
  });

  var myMapSize = &apos;medium&apos;;
  [].slice.call(document.querySelectorAll(&apos;.wf-size&apos;)).forEach(function (b) {
    b.addEventListener(&apos;click&apos;, function () {
      myMapSize = b.getAttribute(&apos;data-size&apos;);
      [].slice.call(document.querySelectorAll(&apos;.wf-size&apos;)).forEach(function (x) { x.classList.toggle(&apos;sel&apos;, x === b); });
    });
  });

  function api(path, opts) {
    return fetch(NET_URL + path, opts).then(function (r) {
      if (!r.ok) throw new Error(&apos;http &apos; + r.status);
      return r.json();
    });
  }
  function pingRoom(code) {
    var t0 = (window.performance || Date).now();
    return fetch(NET_URL + &apos;/room/&apos; + code + &apos;/ping&apos;, { cache: &apos;no-store&apos; })
      .then(function () { return Math.round((window.performance || Date).now() - t0); })
      .catch(function () { return 9999; });
  }
  function escapeHtml(s) {
    return String(s).replace(/[&amp;&lt;&gt;&quot;]/g, function (c) {
      return { &apos;&amp;&apos;: &apos;&amp;amp;&apos;, &apos;&lt;&apos;: &apos;&amp;lt;&apos;, &apos;&gt;&apos;: &apos;&amp;gt;&apos;, &apos;&quot;&apos;: &apos;&amp;quot;&apos; }[c];
    });
  }

  // ---- screens --------------------------------------------------------
  var screen = &apos;menu&apos;;   // menu | lobby | match | ended
  function showScreen(s) {
    screen = s;
    menuEl.hidden = s !== &apos;menu&apos;;
    lobbyEl.hidden = s !== &apos;lobby&apos;;
    exitBtn.hidden = s === &apos;menu&apos;;
    // The arena and the scores stay up through &apos;ended&apos; so the final board is
    // there to look at, with the result drawn over the middle of it. Only the
    // controls go away, since there is nothing left to type.
    var onBoard = s === &apos;match&apos; || s === &apos;ended&apos;;
    scoreEl.hidden = !onBoard;
    canvas.hidden = !onBoard;
    inputbarEl.hidden = s !== &apos;match&apos;;
    keysEl.hidden = s !== &apos;match&apos;;
    hintEl.hidden = s !== &apos;match&apos;;
    if (s !== &apos;match&apos;) spectatingEl.hidden = true;
  }

  // ---- connection -------------------------------------------------------
  var ws = null, myId = null, grid = null, players = {}, myWords = null;
  var mySpecialReadyAt = 0, myTrap = null, myVanishUntil = 0, myChargedUntil = 0;
  var kits = [];
  var iAmReady = false;
  var typedBuf = &apos;&apos;;

  // Positions snap on every state message since nothing ticks between them;
  // renderPos eases toward the real (x,y) each frame so a move reads as a
  // step rather than a teleport. facing remembers the last direction moved
  // or attacked, purely for orienting the character&apos;s accessory on canvas —
  // the server has no concept of facing, actions are direction-explicit.
  var renderPos = {}, facing = {}, fx = [], swings = {};
  var endedText = &apos;&apos;, endedSelf = false, reconnectNote = &apos;&apos;;

  // What it takes to get back into the same seat after the socket dies.
  var joined = null;          // {code, pw, token}
  var leaving = false, retries = 0, retryTimer = null;

  var roomInvite = &apos;&apos;;        // the key that lets a link skip the password
  var joinKey = &apos;&apos;;           // the key we ourselves arrived with, if any

  function connect(code, password, token, key) {
    if (key !== undefined) joinKey = key || &apos;&apos;;
    var name = (nameEl.value || &apos;you&apos;).slice(0, 12);
    try { localStorage.setItem(&apos;wf_name&apos;, name); } catch (e) {}
    joined = { code: code, pw: password || &apos;&apos;, token: token || (joined &amp;&amp; joined.code === code ? joined.token : &apos;&apos;) };
    leaving = false;
    noteEl.textContent = token ? &apos;Reconnecting to &apos; + code + &apos;…&apos; : &apos;Connecting to &apos; + code + &apos;…&apos;;
    var url = NET_URL.replace(/^http/, &apos;ws&apos;) + &apos;/room/&apos; + code + &apos;/ws?name=&apos; +
              encodeURIComponent(name) + &apos;&amp;cls=&apos; + encodeURIComponent(myCls) +
              &apos;&amp;pw=&apos; + encodeURIComponent(password || &apos;&apos;) +
              (joinKey ? &apos;&amp;key=&apos; + encodeURIComponent(joinKey) : &apos;&apos;) +
              (joined.token ? &apos;&amp;token=&apos; + encodeURIComponent(joined.token) : &apos;&apos;);
    ws = new WebSocket(url);
    var opened = false;
    ws.onopen = function () { opened = true; retries = 0; startHeartbeat(); };
    ws.onmessage = function (ev) {
      var msg;
      try { msg = JSON.parse(ev.data); } catch (e) { return; }
      if (msg.type === &apos;hello&apos;) { if (joined) joined.token = msg.token; reconnectNote = &apos;&apos;; return; }
      if (msg.type === &apos;lobby&apos;) onLobby(msg);
      else if (msg.type === &apos;start&apos;) onStart(msg);
      else if (msg.type === &apos;state&apos;) onState(msg);
      else if (msg.type === &apos;ended&apos;) onEnded(msg);
    };
    ws.onclose = function () {
      ws = null;
      stopHeartbeat();
      // A phone closes the socket seconds after the tab goes to the background,
      // so a close is not the same thing as leaving. Keep trying to get the seat
      // back — it is held open for a while — and only fall back to the menu once
      // the attempts run out.
      if (leaving || screen === &apos;menu&apos;) return;
      // Never opened and no seat to reclaim: the room was refused, not
      // interrupted — a code that does not exist, or a wrong password. Retrying
      // six times would only delay saying so.
      if (!opened &amp;&amp; !(joined &amp;&amp; joined.token)) {
        var wanted = joined ? joined.code : &apos;&apos;;
        joined = null; joinKey = &apos;&apos;; reconnectNote = &apos;&apos;;
        showScreen(&apos;menu&apos;);
        noteEl.textContent = wanted
          ? &apos;Room &apos; + wanted + &apos; is not there — it may have closed, or the link is wrong.&apos;
          : &apos;Could not join that room.&apos;;
        return;
      }
      if (retries &lt; 6 &amp;&amp; joined) {
        var wait = Math.min(4000, 250 * Math.pow(2, retries));
        retries++;
        noteEl.textContent = &apos;Connection lost — reconnecting…&apos;;
        reconnectNote = &apos;Reconnecting…&apos;;
        // Hold the seat we are actually retrying. Leaving the room, or starting
        // a bot match, clears `joined` — so a timer that read it at fire time
        // would either throw or drag us back into a room we had left.
        var target = joined;
        retryTimer = setTimeout(function () {
          retryTimer = null;
          if (leaving || joined !== target) return;
          connect(target.code, target.pw, target.token);
        }, wait);
      } else {
        reconnectNote = &apos;&apos;;
        showScreen(&apos;menu&apos;);
        noteEl.textContent = &apos;Disconnected.&apos;;
      }
    };
    ws.onerror = function () {};
  }

  function send(obj) {
    if (botMode) { handleLocalAction(obj); return; }
    if (ws &amp;&amp; ws.readyState === 1) ws.send(JSON.stringify(obj));
  }

  // ---- play vs bot --------------------------------------------------------
  // No server involved at all: the exact same sim runs locally, bots &quot;type&quot;
  // on a timer instead of a real person tapping keys, and everything else —
  // rendering, the legend, auto-submit, animations — is the code already
  // built for the online game. A bot is just a different transport feeding
  // onState() the same shape of message a room&apos;s WebSocket would.
  var botMode = false, botWorld = null, botTimers = {};
  var BOT_NAMES = [&apos;Nyx&apos;, &apos;Rook&apos;, &apos;Vex&apos;, &apos;Ori&apos;, &apos;Fenn&apos;];
  var BOT_CPS = 50 * 5 / 60;   // 50 wpm at ~5 chars/word average

  function localStateFor(world, viewerId) {
    var now = Date.now();
    var players = {};
    for (var id in world.players) {
      var p = world.players[id];
      var mine = id === viewerId;
      var hideMe = !mine &amp;&amp; S.isVanished(p, now);
      players[id] = {
        cls: p.cls, name: p.name, hp: p.hp, dead: p.dead,
        x: hideMe ? null : p.x, y: hideMe ? null : p.y,
        hidden: hideMe, stunned: S.isStunned(p, now),
        stunUntil: p.stunnedUntil || 0, stunSpan: p.stunSpan || 0,
        charged: !!p.charged &amp;&amp; (!p.chargedUntil || p.chargedUntil &gt; now)
      };
    }
    var me = world.players[viewerId];
    return {
      type: &apos;state&apos;, you: viewerId, players: players,
      words: me &amp;&amp; !me.dead ? me.words : null,
      specialReadyAt: me ? me.specialCooldownUntil : null,
      vanishUntil: me ? me.vanishUntil : 0,
      chargedUntil: me ? (me.charged ? me.chargedUntil : 0) : 0,
      kits: world.kits.map(function (k) { return { x: k.x, y: k.y }; }),
      trap: me &amp;&amp; world.traps[viewerId] ? world.traps[viewerId] : null
    };
  }

  function localBroadcast(event) {
    var payload = localStateFor(botWorld, &apos;me&apos;);
    if (event) payload.event = event;
    onState(payload);
    scheduleLocalRefresh();
    var winner = S.checkWinner(botWorld);
    if (winner !== undefined) { stopBots(); onEnded({ winner: winner }); }
  }

  // Same reason as the server&apos;s version: vanish and stun end on a clock, but
  // state only otherwise refreshes when somebody acts.
  var localRefreshTimer = null;
  function scheduleLocalRefresh() {
    if (localRefreshTimer) { clearTimeout(localRefreshTimer); localRefreshTimer = null; }
    if (!botMode || !botWorld) return;
    var now = Date.now(), next = botWorld.nextKitAt || Infinity;
    for (var id in botWorld.players) {
      var p = botWorld.players[id];
      if (p.vanishUntil &gt; now) next = Math.min(next, p.vanishUntil);
      if (p.stunnedUntil &gt; now) next = Math.min(next, p.stunnedUntil);
    }
    if (next === Infinity) return;
    localRefreshTimer = setTimeout(function () {
      localRefreshTimer = null;
      if (!botMode || !botWorld) return;
      S.spawnKits(botWorld, Date.now());
      localBroadcast(null);
    }, Math.max(20, next - now + 25));
  }

  function handleLocalAction(obj) {
    if (obj.type !== &apos;action&apos; || !botWorld) return;
    var result = S.resolveAction(botWorld, &apos;me&apos;, obj.slot, Date.now());
    if (result) localBroadcast(result);
  }

  function stopBots() {
    for (var id in botTimers) clearTimeout(botTimers[id]);
    botTimers = {};
    if (localRefreshTimer) { clearTimeout(localRefreshTimer); localRefreshTimer = null; }
  }

  var STEPS = [[&apos;moveUp&apos;, 0, -1], [&apos;moveDown&apos;, 0, 1], [&apos;moveLeft&apos;, -1, 0], [&apos;moveRight&apos;, 1, 0]];

  // Breadth-first search for the first step of a real shortest path, because
  // stepping greedily toward the target does not work on a map with walls:
  // put a wall between bot and target and greedy stepping oscillates between
  // two tiles forever, which is exactly the &quot;bots stand there doing nothing&quot;
  // behaviour. Rivers block walking too, but map generation guarantees the
  // floor is connected without crossing one, so a path always exists.
  function botStepToward(world, p, tx, ty) {
    var startKey = p.x + &apos;,&apos; + p.y;
    var seen = {}, prev = {}, q = [[p.x, p.y]];
    seen[startKey] = true;
    while (q.length) {
      var cur = q.shift(), cx = cur[0], cy = cur[1];
      if (cx === tx &amp;&amp; cy === ty) {
        var k = cx + &apos;,&apos; + cy;
        while (prev[k] &amp;&amp; prev[k].from !== startKey) k = prev[k].from;
        return prev[k] ? prev[k].slot : null;
      }
      for (var i = 0; i &lt; STEPS.length; i++) {
        var nx = cx + STEPS[i][1], ny = cy + STEPS[i][2];
        if (nx &lt; 0 || ny &lt; 0 || nx &gt;= world.w || ny &gt;= world.h) continue;
        var nk = nx + &apos;,&apos; + ny;
        if (seen[nk]) continue;
        var tile = world.grid[ny][nx];
        if (tile === S.WALL || tile === S.RIVER) continue;
        // The goal tile is occupied by the target, so allow it as a
        // destination but never as something to route through.
        if (world.occupancy[nk] &amp;&amp; !(nx === tx &amp;&amp; ny === ty)) continue;
        seen[nk] = true;
        prev[nk] = { from: cx + &apos;,&apos; + cy, slot: STEPS[i][0] };
        q.push([nx, ny]);
      }
    }
    return null;
  }

  // Which attack direction, if any, actually lands on this target — asked of
  // the sim&apos;s own shape logic rather than re-derived here, so a bot can never
  // think it has reach the real rules disagree with (fighter&apos;s swipe is wide,
  // ranger&apos;s shot is a line, the mage&apos;s beam is stopped by walls but not
  // rivers, and so on).
  function botAttackDir(world, p, targetId) {
    var shape = S.CLASSES[p.cls].attackShape;
    var dirs = [&apos;up&apos;, &apos;down&apos;, &apos;left&apos;, &apos;right&apos;];
    for (var i = 0; i &lt; dirs.length; i++) {
      var swept = S.targetsInShape(world, p, dirs[i], shape);
      if (swept.hits.indexOf(targetId) !== -1) {
        return &apos;atk&apos; + dirs[i].charAt(0).toUpperCase() + dirs[i].slice(1);
      }
    }
    return null;
  }

  // Hunt the nearest living target: shoot it if it is already in reach, use
  // the special when there is a concrete reason to, otherwise walk one step
  // along the shortest path to it. Deliberately simple — it is a practice
  // opponent, not a rival — but it does have to actually show up and fight.
  function decideBotAction(world, id) {
    var now = Date.now();
    var p = world.players[id];
    var def = S.CLASSES[p.cls];
    var targets = Object.keys(world.players).filter(function (oid) {
      return oid !== id &amp;&amp; !world.players[oid].dead &amp;&amp; !S.isVanished(world.players[oid], now);
    });
    if (!targets.length) return null;
    targets.sort(function (a, b) {
      var pa = world.players[a], pb = world.players[b];
      return (Math.abs(pa.x - p.x) + Math.abs(pa.y - p.y)) - (Math.abs(pb.x - p.x) + Math.abs(pb.y - p.y));
    });
    var tid = targets[0], t = world.players[tid];
    var gap = Math.abs(t.x - p.x) + Math.abs(t.y - p.y);

    if (p.specialCooldownUntil &lt;= now) {
      if (def.special.kind === &apos;vanish&apos; &amp;&amp; p.hp &lt;= 40 &amp;&amp; gap &lt;= 3) return { slot: &apos;special&apos; };
      if (def.special.kind === &apos;shieldStun&apos; &amp;&amp;
          S.targetsInShape(world, p, null, def.special.shape || &apos;self3x3&apos;).hits.length) return { slot: &apos;special&apos; };
      // A trap only pays off on ground somebody might cross, so lay it while
      // the target is still a little way off rather than mid-brawl.
      if (def.special.kind === &apos;trap&apos; &amp;&amp; !world.traps[id] &amp;&amp; gap &gt; 2) return { slot: &apos;special&apos; };
    }

    var atk = botAttackDir(world, p, tid);
    // Worth giving up an ordinary beam for: the charged one is more than double
    // the damage across three lanes. Measured the other way round, a bot that
    // only charged on spare turns almost never used it at all.
    if (def.special.kind === &apos;supercharge&apos; &amp;&amp; !p.charged &amp;&amp; p.specialCooldownUntil &lt;= now &amp;&amp;
        (!atk || gap &gt; 1)) {
      return { slot: &apos;special&apos; };
    }
    if (atk) return { slot: atk };

    // Specials that are not better than a hit wait for a turn with no hit to
    // give up. Healing while a target stands lined up in front of you is simply
    // bad play, and it also skewed measurements of the mage badly: it made any
    // replacement special look strong purely by not doing that.
    if (p.specialCooldownUntil &lt;= now &amp;&amp; def.special.kind === &apos;heal&apos; &amp;&amp; p.hp &lt;= 60) {
      return { slot: &apos;special&apos; };
    }

    // Hurt and a med kit within reach: go and get it. Worth more than another
    // exchange when a couple of hits would finish you.
    if (p.hp &lt;= 55 &amp;&amp; world.kits &amp;&amp; world.kits.length) {
      var nearest = null, nd = Infinity;
      for (var ki = 0; ki &lt; world.kits.length; ki++) {
        var k = world.kits[ki];
        var kd = Math.abs(k.x - p.x) + Math.abs(k.y - p.y);
        if (kd &lt; nd) { nd = kd; nearest = k; }
      }
      // Only if it is closer than the fight, otherwise it is a long walk while
      // being shot at.
      if (nearest &amp;&amp; nd &lt;= 6 &amp;&amp; nd &lt;= gap + 2) {
        var kstep = botStepToward(world, p, nearest.x, nearest.y);
        if (kstep) return { slot: kstep };
      }
    }

    var step = botStepToward(world, p, t.x, t.y);
    if (step) return { slot: step };

    // Boxed in for the moment (usually another body in a corridor) — shuffle
    // somewhere legal rather than freezing, so it never looks switched off.
    for (var i = 0; i &lt; STEPS.length; i++) {
      var s = STEPS[(i + Math.floor(Math.random() * STEPS.length)) % STEPS.length];
      if (S.walkable(world, p.x + s[1], p.y + s[2])) return { slot: s[0] };
    }
    return null;
  }

  function scheduleBot(id) {
    if (!botMode) return;
    var p = botWorld.players[id];
    if (!p || p.dead) return;
    var decision = decideBotAction(botWorld, id);
    if (!decision) { botTimers[id] = setTimeout(function () { scheduleBot(id); }, 400); return; }
    var word = S.currentWord(p, decision.slot);
    var delayMs = (word.length / BOT_CPS) * 1000 + 150 + Math.random() * 200;
    botTimers[id] = setTimeout(function () {
      var result = S.resolveAction(botWorld, id, decision.slot, Date.now());
      if (result) localBroadcast(result);
      scheduleBot(id);
    }, delayMs);
  }

  // Play vs Bot goes through the same room flow, so choosing a class and
  // readying up works the same way whether or not anyone else is watching.
  function openBotLobby() {
    botMode = true;
    // Cancel anything still trying to get back into an online room.
    if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
    if (ws) { leaving = true; try { ws.close(); } catch (e) {} ws = null; }
    joined = null;
    retries = 0;
    reconnectNote = &apos;&apos;;
    leaving = false;
    iAmReady = false;
    readyBtn.textContent = &apos;Ready&apos;;
    showScreen(&apos;lobby&apos;);
    renderClassInfo();
    renderBotLobby();
  }

  function renderBotLobby() {
    document.getElementById(&apos;wf-lobby-title&apos;).textContent = &apos;Practice vs bots — &apos; + myMapSize + &apos; map&apos;;
    rosterEl.innerHTML = &apos;&apos;;
    var line = [{ name: nameEl.value || &apos;you&apos;, cls: myCls, ready: iAmReady }]
      .concat(BOT_NAMES.slice(0, 2).map(function (n, i) {
        var others = [&apos;rogue&apos;, &apos;fighter&apos;, &apos;ranger&apos;, &apos;mage&apos;].filter(function (c) { return c !== myCls; });
        return { name: n, cls: others[i % others.length], ready: true };
      }));
    line.forEach(function (p) {
      var row = document.createElement(&apos;div&apos;);
      row.className = &apos;p&apos; + (p.ready ? &apos; ready&apos; : &apos;&apos;);
      row.innerHTML = &apos;&lt;span&gt;&apos; + (EMOJI[p.cls] || &apos;&apos;) + &apos; &apos; + escapeHtml(p.name) + &apos;&lt;/span&gt;&apos; +
                      &apos;&lt;span&gt;&apos; + (p.ready ? &apos;\u2705 ready&apos; : &apos;…&apos;) + &apos;&lt;/span&gt;&apos;;
      rosterEl.appendChild(row);
    });
    countdownEl.textContent = iAmReady ? &apos;&apos; : &apos;Ready up when you are.&apos;;
  }

  function startBotMatch() {
    botMode = true;
    var botCount = 2;
    var seed = Math.floor(Math.random() * 100000);
    botWorld = S.createWorld(seed, botCount + 1, myMapSize);
    var me = S.addPlayer(botWorld, &apos;me&apos;, nameEl.value || &apos;you&apos;, myCls);
    var others = [&apos;rogue&apos;, &apos;fighter&apos;, &apos;ranger&apos;, &apos;mage&apos;].filter(function (c) { return c !== myCls; });
    for (var i = 0; i &lt; botCount; i++) {
      var bid = &apos;bot&apos; + i;
      S.addPlayer(botWorld, bid, BOT_NAMES[i % BOT_NAMES.length], others[i % others.length]);
      scheduleBot(bid);
    }
    onStart({ grid: botWorld.grid });
    localBroadcast(null);
  }

  // Sitting in the lobby, or just thinking through a hard word, can easily
  // take longer than a real player&apos;s connection should be judged idle for —
  // this keeps the server&apos;s per-connection &quot;last seen&quot; fresh regardless of
  // how much actual gameplay is happening.
  var heartbeatTimer = null;
  function startHeartbeat() {
    stopHeartbeat();
    heartbeatTimer = setInterval(function () { send({ type: &apos;ping&apos; }); }, 8000);
  }
  // A phone suspends this tab&apos;s timers the moment it is not visible, so the
  // heartbeat above stops dead while you are in another app and resumes on
  // return. Ping immediately on coming back so the server hears from us at once
  // rather than on the next tick.
  function pokeOnReturn() {
    if (ws &amp;&amp; ws.readyState === 1) { send({ type: &apos;ping&apos; }); return; }
    // Socket died while the tab was hidden — go straight back in rather than
    // waiting out the backoff.
    if (!leaving &amp;&amp; joined &amp;&amp; screen !== &apos;menu&apos; &amp;&amp; !ws) {
      if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
      retries = 0;
      connect(joined.code, joined.pw, joined.token);
    }
  }
  document.addEventListener(&apos;visibilitychange&apos;, function () {
    if (!document.hidden) pokeOnReturn();
  });
  window.addEventListener(&apos;focus&apos;, pokeOnReturn);
  window.addEventListener(&apos;pageshow&apos;, pokeOnReturn);
  function stopHeartbeat() {
    if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
  }

  // ---- lobby --------------------------------------------------------------
  function onLobby(msg) {
    if (screen !== &apos;lobby&apos;) showScreen(&apos;lobby&apos;);
    renderClassInfo();
    document.getElementById(&apos;wf-lobby-title&apos;).textContent =
      msg.room ? msg.room.name + &apos; (&apos; + msg.room.code + &apos;)&apos; +
        (msg.room.mapSize ? &apos; — &apos; + msg.room.mapSize + &apos; map&apos; : &apos;&apos;) : &apos;Room&apos;;
    if (msg.room &amp;&amp; msg.room.code) {
      roomInvite = msg.room.invite || &apos;&apos;;
      inviteBtn.hidden = false;
      inviteUrlEl.value = inviteLink(msg.room.code, roomInvite);
    }
    rosterEl.innerHTML = &apos;&apos;;
    msg.roster.forEach(function (p) {
      var row = document.createElement(&apos;div&apos;);
      row.className = &apos;p&apos; + (p.ready ? &apos; ready&apos; : &apos;&apos;);
      row.innerHTML = &apos;&lt;span&gt;&apos; + (EMOJI[p.cls] || &apos;&apos;) + &apos; &apos; + escapeHtml(p.name) + &apos;&lt;/span&gt;&apos; +
        &apos;&lt;span&gt;&apos; + (p.ready ? &apos;✅ ready&apos; : &apos;…&apos;) + &apos;&lt;/span&gt;&apos;;
      rosterEl.appendChild(row);
    });
    if (msg.phase === &apos;countdown&apos; &amp;&amp; msg.countdownEndsAt) {
      tickCountdown(msg.countdownEndsAt);
    } else {
      countdownEl.textContent = msg.roster.length &lt; 2 ? &apos;Waiting for another player…&apos; : &apos;&apos;;
    }
  }
  var countdownTimer = null;
  function tickCountdown(endsAt) {
    if (countdownTimer) clearInterval(countdownTimer);
    var tick = function () {
      var left = Math.max(0, endsAt - Date.now());
      countdownEl.textContent = &apos;Starting in &apos; + Math.ceil(left / 1000) + &apos;s…&apos;;
      if (left &lt;= 0) clearInterval(countdownTimer);
    };
    tick();
    countdownTimer = setInterval(tick, 200);
  }
  readyBtn.addEventListener(&apos;click&apos;, function () {
    iAmReady = !iAmReady;
    readyBtn.textContent = iAmReady ? &apos;Cancel&apos; : &apos;Ready&apos;;
    if (botMode) {
      // Nobody else to wait for, so there is nothing for a countdown to do.
      if (iAmReady) { startBotMatch(); return; }
      renderBotLobby();
      return;
    }
    send({ type: &apos;ready&apos;, ready: iAmReady });
  });

  var inviteBtn = document.getElementById(&apos;wf-invite&apos;);
  var inviteUrlEl = document.getElementById(&apos;wf-invite-url&apos;);

  function inviteLink(code, key) {
    var base = window.location.origin + window.location.pathname;
    return base + &apos;?room=&apos; + encodeURIComponent(code) + (key ? &apos;&amp;key=&apos; + encodeURIComponent(key) : &apos;&apos;);
  }

  inviteBtn.addEventListener(&apos;click&apos;, function () {
    var url = inviteUrlEl.value;
    function reveal() {
      // Clipboard access is refused in plenty of mobile contexts, so always
      // leave the link on screen to copy by hand.
      inviteUrlEl.hidden = false;
      inviteUrlEl.select &amp;&amp; inviteUrlEl.select();
    }
    if (navigator.clipboard &amp;&amp; navigator.clipboard.writeText) {
      navigator.clipboard.writeText(url).then(function () {
        inviteBtn.textContent = &apos;Link copied&apos;;
        setTimeout(function () { inviteBtn.textContent = &apos;Copy invite link&apos;; }, 1600);
      }).catch(reveal);
    } else reveal();
  });

  // ---- match --------------------------------------------------------------
  function onStart(msg) {
    grid = msg.grid;
    GRID_N = grid.length;   // the room&apos;s chosen map size — 8, 12 or 16
    players = {};
    renderPos = {}; facing = {}; fx = []; swings = {}; kits = [];
    endedText = &apos;&apos;; endedSelf = false;
    myWords = null; myTrap = null;
    iAmReady = false; readyBtn.textContent = &apos;Ready&apos;;
    if (countdownTimer) clearInterval(countdownTimer);
    typedEl.value = &apos;&apos;; typedBuf = &apos;&apos;;
    showScreen(&apos;match&apos;);
    typingDisabled = false;
    typedEl.disabled = false;
    keysEl.classList.remove(&apos;disabled&apos;);
  }

  function onState(msg) {
    myId = msg.you;
    players = msg.players;
    if (msg.words) myWords = msg.words;
    mySpecialReadyAt = msg.specialReadyAt || 0;
    myVanishUntil = msg.vanishUntil || 0;
    myChargedUntil = msg.chargedUntil || 0;
    kits = msg.kits || [];
    myTrap = msg.trap || null;
    for (var id in players) {
      var p = players[id];
      if (!p.dead &amp;&amp; !p.hidden &amp;&amp; !renderPos[id]) renderPos[id] = { x: p.x, y: p.y };
    }
    if (msg.event) applyEventFx(msg.event);
    var me = players[myId];
    spectatingEl.hidden = !(me &amp;&amp; me.dead);
    if (me &amp;&amp; me.dead) { typingDisabled = true; typedEl.disabled = true; keysEl.classList.add(&apos;disabled&apos;); }
    renderScore();
  }

  function onEnded(msg) {
    var w = players[msg.winner];
    endedText = w ? (EMOJI[w.cls] || &apos;&apos;) + &apos; &apos; + w.name + &apos; wins!&apos; : &apos;No one left standing&apos;;
    endedSelf = msg.winner === myId;
    showScreen(&apos;ended&apos;);
    typingDisabled = true;
    typedEl.disabled = true;
    keysEl.classList.add(&apos;disabled&apos;);
  }

  function renderScore() {
    scoreEl.innerHTML = &apos;&apos;;
    for (var id in players) {
      var p = players[id];
      var el = document.createElement(&apos;div&apos;);
      el.className = &apos;who&apos; + (id === myId ? &apos; self&apos; : &apos;&apos;) + (p.dead ? &apos; dead&apos; : &apos;&apos;);
      el.innerHTML = (EMOJI[p.cls] || &apos;&apos;) + &apos; &apos; + escapeHtml(p.name) +
        &apos; &lt;span class=&quot;hp&quot;&gt;&apos; + p.hp + &apos;&lt;/span&gt;&apos;;
      scoreEl.appendChild(el);
    }
  }

  // Turns one resolved-action event into: an updated facing direction (so the
  // character&apos;s accessory points the right way), a brief flash over every
  // tile the attack actually swept, a floating number per hit, and a small
  // pulse for whichever special just fired. Purely cosmetic — the event
  // already changed real state before this ever runs.
  function applyEventFx(ev) {
    var now = Date.now();
    var actor = players[ev.id];
    if (ev.slot &amp;&amp; ev.slot.indexOf(&apos;move&apos;) === 0 &amp;&amp; ev.ok) {
      facing[ev.id] = ev.slot.slice(4).toLowerCase();
    }
    if (ev.slot &amp;&amp; ev.slot.indexOf(&apos;atk&apos;) === 0 &amp;&amp; ev.dir) {
      facing[ev.id] = ev.dir;
      swings[ev.id] = { born: now, until: now + 230 };
    }
    if (ev.tiles &amp;&amp; ev.tiles.length) {
      fx.push({ type: &apos;tiles&apos;, tiles: ev.tiles,
        color: ev.special === &apos;shieldStun&apos; ? &apos;rgba(192,57,43,.45)&apos; : &apos;rgba(241,196,15,.5)&apos;,
        until: now + 220 });
    }
    if (ev.hits) {
      ev.hits.forEach(function (h) {
        var t = players[h.id];
        if (t) fx.push({ type: &apos;dmg&apos;, x: t.x, y: t.y, text: &apos;-&apos; + h.dmg, born: now, until: now + 700 });
      });
    }
    if (ev.special === &apos;vanish&apos; &amp;&amp; actor) {
      fx.push({ type: &apos;pulse&apos;, x: actor.x, y: actor.y, color: &apos;rgba(142,68,173,.55)&apos;, born: now, until: now + 320 });
    }
    if (ev.special === &apos;heal&apos; &amp;&amp; actor) {
      fx.push({ type: &apos;pulse&apos;, x: actor.x, y: actor.y, color: &apos;rgba(46,139,87,.55)&apos;, born: now, until: now + 320 });
      fx.push({ type: &apos;dmg&apos;, x: actor.x, y: actor.y, text: &apos;+heal&apos;, born: now, until: now + 700, good: true });
    }
    if (ev.kit) {
      fx.push({ type: &apos;pulse&apos;, x: ev.kit.x, y: ev.kit.y, color: &apos;rgba(46,139,87,.6)&apos;, born: now, until: now + 380 });
      fx.push({ type: &apos;dmg&apos;, x: ev.kit.x, y: ev.kit.y, text: &apos;+&apos; + ev.kit.healed, born: now, until: now + 800, good: true });
    }
    if (ev.charged &amp;&amp; ev.slot &amp;&amp; ev.slot.indexOf(&apos;atk&apos;) === 0 &amp;&amp; actor) {
      fx.push({ type: &apos;pulse&apos;, x: actor.x, y: actor.y, color: &apos;rgba(142,68,173,.75)&apos;, born: now, until: now + 420 });
    }
    if (ev.special === &apos;supercharge&apos; &amp;&amp; actor) {
      fx.push({ type: &apos;pulse&apos;, x: actor.x, y: actor.y, color: &apos;rgba(155,89,182,.65)&apos;, born: now, until: now + 360 });
    }
    if (ev.trapPlaced &amp;&amp; actor) {
      fx.push({ type: &apos;pulse&apos;, x: ev.trapPlaced.x, y: ev.trapPlaced.y, color: &apos;rgba(211,84,0,.5)&apos;, born: now, until: now + 320 });
    }
    if (ev.trapTriggered &amp;&amp; actor) {
      fx.push({ type: &apos;pulse&apos;, x: actor.x, y: actor.y, color: &apos;rgba(211,84,0,.6)&apos;, born: now, until: now + 320 });
    }
  }

  // ---- typing ---------------------------------------------------------
  // A custom keyboard, not the OS one: on a phone the real keyboard eats
  // roughly half the screen and its height changes unpredictably as it
  // opens, so the map either disappears or jumps around mid-match. This one
  // has a fixed, known height, so the arena above it is always visible. The
  // input itself is `readonly` — tapping it never summons the real keyboard —
  // but a physical keyboard (desktop, or a phone with one paired) still works
  // via the document-level keydown listener below.
  //
  // The buffer decides its own target slot: whichever of my 9 live words it
  // exactly matches. Space/Enter/the on-screen space bar submits; a non-match
  // just clears, no penalty beyond the time spent typing it.
  var typingDisabled = false;

  function typeChar(c) {
    if (typingDisabled) return;
    typedBuf = (typedBuf + c).slice(0, 16);   // no live word is anywhere near this long
    typedEl.value = typedBuf;
    autoSubmitIfUnambiguous();
  }
  function backspace() {
    if (typingDisabled) return;
    typedBuf = typedBuf.slice(0, -1);
    typedEl.value = typedBuf;
    autoSubmitIfUnambiguous();
  }
  // &quot;as&quot; typed against {as, ash} still needs an explicit submit — it&apos;s an
  // exact match, but also a real prefix of a longer live word, so finishing
  // there could still mean &quot;ash&quot; is coming. Only fire early once the buffer
  // exactly matches a word AND no other live word extends past that same
  // buffer — e.g. typing the &quot;h&quot; in &quot;ash&quot; completes it unambiguously.
  function autoSubmitIfUnambiguous() {
    if (!myWords) return;
    var buf = typedBuf.trim().toLowerCase();
    if (!buf) return;
    var exact = null, ambiguous = false;
    for (var slot in myWords) {
      var w = (myWords[slot][0] || &apos;&apos;).toLowerCase();
      if (!w) continue;
      if (w === buf) exact = slot;
      else if (w.length &gt; buf.length &amp;&amp; w.indexOf(buf) === 0) ambiguous = true;
    }
    if (exact &amp;&amp; !ambiguous) submitTyped();
  }
  function submitTyped() {
    if (typingDisabled) return;
    var word = typedBuf.trim().toLowerCase();
    typedBuf = &apos;&apos;; typedEl.value = &apos;&apos;;
    if (!word || !myWords) return;
    for (var slot in myWords) {
      if ((myWords[slot][0] || &apos;&apos;).toLowerCase() === word) {
        send({ type: &apos;action&apos;, slot: slot, word: word });
        return;
      }
    }
    // no match: nothing happens, buffer is already cleared
  }

  function keySpacer(cls) {
    var s = document.createElement(&apos;span&apos;);
    s.className = &apos;wf-key pad &apos; + cls;
    return s;
  }

  // Touch and click are both handled on the container, and a press is resolved
  // to the NEAREST key rather than whatever element happens to sit under the
  // finger. Two reasons. First, the 4px gaps between keys and the invisible
  // spacers that produce the stagger were dead zones — a thumb landing between
  // s and d registered nothing. Second, two fingers landing at once arrive as a
  // single touchstart on whichever key was hit first, carrying both touch
  // points, so per-key listeners never hear about the second finger.
  //
  // Vertical distance is weighted heavier than horizontal, which makes the
  // outermost keys generous outwards: the space left of A, or the gap where a
  // phone would put shift next to Z, belongs to that row&apos;s edge key rather than
  // jumping to whatever sits diagonally above it.
  var keyEls = [];
  var ROW_BIAS = 1.35;

  function nearestKey(x, y) {
    var best = null, bestD = Infinity, bestH = 40;
    for (var i = 0; i &lt; keyEls.length; i++) {
      var r = keyEls[i].getBoundingClientRect();
      if (!r.width) continue;
      var dx = x &lt; r.left ? r.left - x : (x &gt; r.right ? x - r.right : 0);
      var dy = (y &lt; r.top ? r.top - y : (y &gt; r.bottom ? y - r.bottom : 0)) * ROW_BIAS;
      var d = dx * dx + dy * dy;
      if (d &lt; bestD) { bestD = d; best = keyEls[i]; bestH = r.height; }
    }
    // Far enough from every key that it was not aimed at the keyboard at all.
    var reach = bestH * 1.5;
    return bestD &lt;= reach * reach ? best : null;
  }

  function pressFlash(el) {
    el.classList.add(&apos;down&apos;);
    setTimeout(function () { el.classList.remove(&apos;down&apos;); }, 90);
  }
  function pressAt(x, y) {
    var el = nearestKey(x, y);
    if (el &amp;&amp; el._wfPress) { el._wfPress(); pressFlash(el); }
  }

  keysEl.addEventListener(&apos;touchstart&apos;, function (ev) {
    ev.preventDefault();          // also stops touch synthesising a click
    for (var i = 0; i &lt; ev.changedTouches.length; i++) {
      pressAt(ev.changedTouches[i].clientX, ev.changedTouches[i].clientY);
    }
  }, { passive: false });
  keysEl.addEventListener(&apos;click&apos;, function (ev) { pressAt(ev.clientX, ev.clientY); });

  [&apos;qwertyuiop&apos;, &apos;asdfghjkl&apos;, &apos;zxcvbnm&apos;].forEach(function (letters) {
    var row = keysEl.querySelector(&apos;[data-row=&quot;&apos; + letters + &apos;&quot;]&apos;);
    // Left-hand spacer: half a key under ASDF, a key and a half under ZXCV.
    if (letters === &apos;asdfghjkl&apos;) row.appendChild(keySpacer(&apos;half&apos;));
    if (letters === &apos;zxcvbnm&apos;) row.appendChild(keySpacer(&apos;wide&apos;));
    letters.split(&apos;&apos;).forEach(function (ch) {
      var b = document.createElement(&apos;button&apos;);
      b.className = &apos;wf-key&apos;; b.type = &apos;button&apos;; b.textContent = ch;
      b._wfPress = function () { typeChar(ch); };
      keyEls.push(b);
      row.appendChild(b);
    });
    if (letters === &apos;asdfghjkl&apos;) row.appendChild(keySpacer(&apos;half&apos;));
    if (letters === &apos;zxcvbnm&apos;) {
      // Backspace lives right after M, where a phone keyboard puts it.
      var back = document.createElement(&apos;button&apos;);
      back.className = &apos;wf-key wide&apos;; back.type = &apos;button&apos;; back.textContent = &apos;⌫&apos;;
      back._wfPress = backspace;
      keyEls.push(back);
      row.appendChild(back);
    }
  });
  var spaceKey = keysEl.querySelector(&apos;[data-key=&quot;space&quot;]&apos;);
  spaceKey._wfPress = submitTyped;
  keyEls.push(spaceKey);

  document.addEventListener(&apos;keydown&apos;, function (ev) {
    if (screen !== &apos;match&apos;) return;
    if (ev.key === &apos; &apos; || ev.key === &apos;Enter&apos;) { ev.preventDefault(); submitTyped(); }
    else if (ev.key === &apos;Backspace&apos;) { ev.preventDefault(); backspace(); }
    else if (/^[a-zA-Z]$/.test(ev.key)) { typeChar(ev.key.toLowerCase()); }
  });

  // ---- rendering --------------------------------------------------------
  var GRID_N = 16;

  // The words live in a gutter around the grid rather than painted on top of
  // it. Overlaid, they hid whole tiles — and a map whose only way through a
  // wall band is in an edge column (common enough: three of the first four
  // thousand seeds) then looks completely sealed off, because the doorway is
  // underneath a word. The gutter costs a little grid size and no page height.
  var view = { cell: 1, x0: 0, y0: 0, grid: 1, pill: 12 };

  function pillSize() { return Math.max(11, canvas.width * 0.036); }

  function layout() {
    var pill = pillSize();
    var gutter = pill * 2.35;            // two stacked words, plus a little air
    var g = canvas.width - gutter * 2;
    view = { cell: g / GRID_N, x0: gutter, y0: gutter, grid: g, pill: pill, gutter: gutter };
    return view;
  }

  // The height budget is measured once and then cached, because a phone&apos;s
  // address bar collapses and expands as you scroll, which changes
  // window.innerHeight and fires `resize` — recomputing on every one of those
  // made the board jump between two sizes while scrolling. Only a genuine
  // layout change (rotation, fullscreen) re-measures it; a resize event that
  // leaves the width alone is ignored.
  var heightBudget = 0, lastStageW = -1, tallestSeen = 0;
  function resizeCanvas(remeasure) {
    var keyH = (window.innerWidth || 400) &lt;= 400 ? 44 : 48;
    var reserve = keyH * 4 + 20 + 150;
    // Track the tallest viewport seen and budget against that. A phone&apos;s
    // address bar only ever gives height back, so the tallest reading is the
    // stable one; budgeting against whatever it happens to be right now made
    // the arena jump between sizes.
    tallestSeen = Math.max(tallestSeen, window.innerHeight || 800);
    if (remeasure || !heightBudget) heightBudget = tallestSeen - reserve;
    var stageW = stage.clientWidth;
    // Ignore the sub-pixel width wobble iOS reports while scrolling.
    if (!remeasure &amp;&amp; Math.abs(stageW - lastStageW) &lt;= 4) return;
    lastStageW = stageW;
    var cssW = Math.min(stageW, 560, Math.max(220, heightBudget));
    canvas.style.width = cssW + &apos;px&apos;;
    canvas.width = Math.round(cssW * (window.devicePixelRatio || 1));
    // Square grid plus equal gutters, and one extra row along the bottom for
    // the special — which used to sit on the middle tile and hide it.
    canvas.height = Math.round(canvas.width + pillSize() * 1.35);
    layout();
  }
  window.addEventListener(&apos;resize&apos;, function () { resizeCanvas(false); });
  window.addEventListener(&apos;orientationchange&apos;, function () { resizeCanvas(true); });
  resizeCanvas(true);

  function draw() {
    requestAnimationFrame(draw);
    if ((screen !== &apos;match&apos; &amp;&amp; screen !== &apos;ended&apos;) || !grid) return;
    var v = layout(), cell = v.cell, x0 = v.x0, y0 = v.y0;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (var y = 0; y &lt; GRID_N; y++) {
      for (var x = 0; x &lt; GRID_N; x++) {
        var t = grid[y][x];
        ctx.fillStyle = t === S.WALL ? &apos;#2b2b2b&apos; : t === S.RIVER ? &apos;#bcdcf0&apos; : &apos;#fbfbf7&apos;;
        ctx.fillRect(x0 + x * cell, y0 + y * cell, cell, cell);
      }
    }
    ctx.strokeStyle = &apos;rgba(0,0,0,.06)&apos;;
    ctx.lineWidth = 1;
    for (var i = 0; i &lt;= GRID_N; i++) {
      ctx.beginPath(); ctx.moveTo(x0 + i * cell, y0); ctx.lineTo(x0 + i * cell, y0 + v.grid); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(x0, y0 + i * cell); ctx.lineTo(x0 + v.grid, y0 + i * cell); ctx.stroke();
    }
    ctx.strokeStyle = &apos;rgba(0,0,0,.5)&apos;;
    ctx.lineWidth = 1.5;
    ctx.strokeRect(x0, y0, v.grid, v.grid);

    // my own trap: only I can see it
    if (myTrap) {
      ctx.fillStyle = &apos;rgba(211,84,0,.35)&apos;;
      ctx.beginPath();
      ctx.arc(x0 + (myTrap.x + .5) * cell, y0 + (myTrap.y + .5) * cell, cell * .3, 0, 7);
      ctx.fill();
    }

    var now = Date.now();
    fx = fx.filter(function (f) { return f.until &gt; now; });

    // Med kits: a white box with a green cross, breathing gently so it catches
    // the eye against the floor without shouting.
    for (var ki = 0; ki &lt; kits.length; ki++) {
      var k = kits[ki];
      var bob = 1 + 0.06 * Math.sin(now / 260 + ki);
      var half = cell * 0.26 * bob;
      var kx = x0 + (k.x + .5) * cell, ky = y0 + (k.y + .5) * cell;
      ctx.fillStyle = &apos;#fff&apos;;
      roundRect(kx - half, ky - half, half * 2, half * 2, half * 0.35); ctx.fill();
      ctx.strokeStyle = &apos;#2e8b57&apos;;
      ctx.lineWidth = Math.max(1, cell * 0.05);
      roundRect(kx - half, ky - half, half * 2, half * 2, half * 0.35); ctx.stroke();
      ctx.strokeStyle = &apos;#2e8b57&apos;;
      ctx.lineWidth = Math.max(1.6, cell * 0.09);
      ctx.beginPath();
      ctx.moveTo(kx - half * 0.5, ky); ctx.lineTo(kx + half * 0.5, ky);
      ctx.moveTo(kx, ky - half * 0.5); ctx.lineTo(kx, ky + half * 0.5);
      ctx.stroke();
    }

    // Attack/special sweeps flash under the characters, not over them, so
    // whoever got hit is still clearly visible on top of the flash.
    fx.forEach(function (f) {
      if (f.type !== &apos;tiles&apos;) return;
      ctx.fillStyle = f.color;
      f.tiles.forEach(function (t) { ctx.fillRect(x0 + t[0] * cell, y0 + t[1] * cell, cell, cell); });
    });

    // Position eases toward the real tile each frame instead of snapping,
    // so a move reads as a step. Snap once it&apos;s close enough that easing
    // further would just be sub-pixel churn.
    for (var rid in players) {
      var rp = players[rid];
      if (rp.dead || rp.hidden) continue;
      var r = renderPos[rid] || (renderPos[rid] = { x: rp.x, y: rp.y });
      var dx = rp.x - r.x, dy = rp.y - r.y;
      if (Math.abs(dx) &lt; 0.01 &amp;&amp; Math.abs(dy) &lt; 0.01) { r.x = rp.x; r.y = rp.y; }
      else { r.x += dx * 0.35; r.y += dy * 0.35; }
    }

    var colorOf = {}, ci = 0;
    for (var pid in players) colorOf[pid] = COLORS[ci++ % COLORS.length];

    for (var id in players) {
      var p = players[id];
      if (p.dead || p.hidden) continue;
      var rr = renderPos[id];
      var cx = x0 + (rr.x + .5) * cell, cy = y0 + (rr.y + .5) * cell;
      var rad = cell * .3;

      // Which dot is you: a slowly breathing amber halo, drawn behind the
      // body so it never obscures the class weapon. A slightly heavier dark
      // outline alone was far too easy to lose track of mid-fight.
      if (id === myId) {
        var pulse = 0.5 + 0.5 * Math.sin(now / 340);
        ctx.strokeStyle = &apos;rgba(243,156,18,&apos; + (0.10 + 0.10 * pulse).toFixed(3) + &apos;)&apos;;
        ctx.lineWidth = Math.max(3, cell * 0.16);
        ctx.beginPath(); ctx.arc(cx, cy, rad * (1.62 + 0.10 * pulse), 0, 7); ctx.stroke();
        ctx.strokeStyle = &apos;rgba(243,156,18,&apos; + (0.75 + 0.25 * pulse).toFixed(3) + &apos;)&apos;;
        ctx.lineWidth = Math.max(2, cell * 0.075);
        ctx.beginPath(); ctx.arc(cx, cy, rad * 1.42, 0, 7); ctx.stroke();
      }

      // How much of your own vanish is left, as a ring that empties clockwise.
      // Only ever your own — an opponent&apos;s vanish is invisible by definition.
      if (id === myId &amp;&amp; myChargedUntil &gt; now) {
        var chDef = S.CLASSES[p.cls].special;
        var chLeft = Math.max(0, Math.min(1, (myChargedUntil - now) / (chDef.holdMs || 5000)));
        ctx.strokeStyle = &apos;#8e44ad&apos;;
        ctx.lineWidth = Math.max(2.5, cell * 0.1);
        ctx.beginPath();
        ctx.arc(cx, cy, rad * 2.1, -Math.PI / 2, -Math.PI / 2 + chLeft * Math.PI * 2);
        ctx.stroke();
      }
      if (id === myId &amp;&amp; myVanishUntil &gt; now) {
        var total = (S.CLASSES[p.cls].special.durationMs) || 3000;
        var left = Math.max(0, Math.min(1, (myVanishUntil - now) / total));
        ctx.strokeStyle = &apos;rgba(142,68,173,.9)&apos;;
        ctx.lineWidth = Math.max(2.5, cell * 0.1);
        ctx.beginPath();
        ctx.arc(cx, cy, rad * 1.78, -Math.PI / 2, -Math.PI / 2 + left * Math.PI * 2);
        ctx.stroke();
      }

      // Holding a charge: crackling arcs that spin, plus orbiting sparks, so a
      // loaded mage is unmistakable — to its owner and to everyone else.
      if (p.charged) {
        var spin = now / 260;
        for (var arcI = 0; arcI &lt; 3; arcI++) {
          var a0 = spin + arcI * (Math.PI * 2 / 3);
          ctx.strokeStyle = &apos;rgba(155,89,182,&apos; + (0.55 + 0.35 * Math.sin(now / 150 + arcI)).toFixed(2) + &apos;)&apos;;
          ctx.lineWidth = Math.max(1.6, cell * 0.07);
          ctx.beginPath();
          ctx.arc(cx, cy, rad * (1.5 + 0.12 * Math.sin(now / 190 + arcI)), a0, a0 + 1.15);
          ctx.stroke();
        }
        for (var sp = 0; sp &lt; 4; sp++) {
          var sa = -spin * 1.7 + sp * (Math.PI / 2);
          var sr = rad * 1.9;
          ctx.fillStyle = &apos;rgba(210,180,222,.9)&apos;;
          ctx.beginPath();
          ctx.arc(cx + Math.cos(sa) * sr, cy + Math.sin(sa) * sr, Math.max(1.2, cell * 0.045), 0, 7);
          ctx.fill();
        }
      }

      ctx.fillStyle = colorOf[id];
      ctx.beginPath(); ctx.arc(cx, cy, rad, 0, 7); ctx.fill();
      ctx.lineWidth = id === myId ? 2.5 : 1.5;
      ctx.strokeStyle = id === myId ? &apos;#222&apos; : &apos;rgba(0,0,0,.25)&apos;;
      ctx.stroke();
      drawFace(cx, cy, rad, DIR_ANGLE[facing[id] || &apos;down&apos;], faceOf(id));
      // Stun shows how much is left, not merely that it is happening: a full
      // red ring that empties clockwise as it wears off.
      if (p.stunned || (p.stunUntil || 0) &gt; now) {
        var span = p.stunSpan || 2000;
        var left = Math.max(0, Math.min(1, ((p.stunUntil || 0) - now) / span));
        ctx.strokeStyle = &apos;rgba(192,57,43,.28)&apos;;
        ctx.lineWidth = Math.max(2, cell * 0.09);
        ctx.beginPath(); ctx.arc(cx, cy, rad * 1.35, 0, 7); ctx.stroke();
        if (left &gt; 0) {
          ctx.strokeStyle = &apos;#c0392b&apos;;
          ctx.beginPath();
          ctx.arc(cx, cy, rad * 1.35, -Math.PI / 2, -Math.PI / 2 + left * Math.PI * 2);
          ctx.stroke();
        }
      }
      var sw = swings[id];
      var swingT = sw &amp;&amp; now &lt; sw.until ? (now - sw.born) / (sw.until - sw.born) : -1;
      drawAccessory(p.cls, cx, cy, rad, DIR_ANGLE[facing[id] || &apos;down&apos;], swingT);
    }

    // Drawn after every body, so no dot can end up on top of a label.
    for (var tid in players) {
      var tp = players[tid];
      if (tp.dead || tp.hidden) continue;
      var tr = renderPos[tid];
      drawIdent(tp, colorOf[tid], x0 + (tr.x + .5) * cell,
                y0 + (tr.y + .5) * cell, cell, cell * .3, tid === myId);
    }

    // Damage numbers and special pulses draw last, on top of everyone.
    fx.forEach(function (f) {
      if (f.type === &apos;pulse&apos;) {
        var age = (now - f.born) / (f.until - f.born);
        ctx.globalAlpha = 1 - age;
        ctx.strokeStyle = f.color; ctx.lineWidth = 3;
        ctx.beginPath();
        ctx.arc(x0 + (f.x + .5) * cell, y0 + (f.y + .5) * cell, cell * (.3 + age * .5), 0, 7);
        ctx.stroke();
        ctx.globalAlpha = 1;
      } else if (f.type === &apos;dmg&apos;) {
        var t = (now - f.born) / (f.until - f.born);
        ctx.globalAlpha = 1 - t;
        ctx.font = &apos;bold &apos; + Math.round(cell * .3) + &apos;px sans-serif&apos;;
        ctx.textAlign = &apos;center&apos;;
        ctx.fillStyle = f.good ? &apos;#2e8b57&apos; : &apos;#c0392b&apos;;
        ctx.fillText(f.text, x0 + (f.x + .5) * cell, y0 + (f.y + .1 - t * .6) * cell);
        ctx.globalAlpha = 1;
      }
    });

    if (screen === &apos;ended&apos; &amp;&amp; endedText) drawResult(v);
    else drawLegend();
    if (reconnectNote) {
      var bh = Math.max(22, v.grid * 0.07);
      ctx.fillStyle = &apos;rgba(192,57,43,.92)&apos;;
      ctx.fillRect(v.x0, v.y0, v.grid, bh);
      ctx.fillStyle = &apos;#fff&apos;;
      ctx.font = &apos;bold &apos; + Math.round(bh * 0.55) + &apos;px Georgia, serif&apos;;
      ctx.textAlign = &apos;center&apos;; ctx.textBaseline = &apos;middle&apos;;
      ctx.fillText(reconnectNote, v.x0 + v.grid / 2, v.y0 + bh / 2);
    }
  }

  // Drawn over the final board rather than replacing it with a panel, so the
  // last position of everyone is still there to read.
  function drawResult(v) {
    var midY = v.y0 + v.grid / 2, h = Math.max(34, v.grid * 0.115);
    ctx.fillStyle = &apos;rgba(251,251,247,.94)&apos;;
    ctx.fillRect(v.x0, midY - h, v.grid, h * 2);
    ctx.strokeStyle = endedSelf ? &apos;#2e8b57&apos; : &apos;#222&apos;;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(v.x0, midY - h); ctx.lineTo(v.x0 + v.grid, midY - h);
    ctx.moveTo(v.x0, midY + h); ctx.lineTo(v.x0 + v.grid, midY + h);
    ctx.stroke();
    ctx.textAlign = &apos;center&apos;; ctx.textBaseline = &apos;middle&apos;;
    ctx.fillStyle = endedSelf ? &apos;#2e8b57&apos; : &apos;#222&apos;;
    ctx.font = &apos;bold &apos; + Math.round(h * 0.62) + &apos;px Georgia, serif&apos;;
    ctx.fillText(endedText, v.x0 + v.grid / 2, midY - h * 0.32);
    ctx.fillStyle = &apos;#999&apos;;
    ctx.font = Math.round(h * 0.38) + &apos;px Georgia, serif&apos;;
    ctx.fillText(botMode ? &apos;Leave to play again&apos; : &apos;Back to the lobby shortly…&apos;,
                 v.x0 + v.grid / 2, midY + h * 0.5);
  }

  // Who is who, and how hurt they are, kept inside the dot&apos;s own cell so a
  // label can never sit on top of another player: the name just above the body,
  // a health bar just below it. A bar rather than a number because you read it
  // without stopping to parse it.
  function drawIdent(p, color, cx, cy, cell, rad, mine) {
    var name = String(p.name || &apos;?&apos;);
    if (name.length &gt; 7) name = name.slice(0, 6) + &apos;\u2026&apos;;
    var frac = Math.max(0, Math.min(1, p.hp / 100));

    var barW = cell * 0.74, barH = Math.max(2.5, cell * 0.1);
    var barY = cy + rad + barH * 1.15;
    // On the bottom row the bar would spill into the word gutter and sit on the
    // legend, so it flips above the body instead.
    if (barY + barH &gt; view.y0 + view.grid - 1) barY = cy - rad - barH * 2.15;
    ctx.save();
    ctx.fillStyle = &apos;rgba(255,255,255,.8)&apos;;
    roundRect(cx - barW / 2, barY, barW, barH, barH / 2); ctx.fill();
    ctx.fillStyle = frac &gt; 0.5 ? &apos;#2e8b57&apos; : frac &gt; 0.25 ? &apos;#d68910&apos; : &apos;#c0392b&apos;;
    if (frac &gt; 0) { roundRect(cx - barW / 2, barY, barW * frac, barH, barH / 2); ctx.fill(); }
    ctx.strokeStyle = &apos;rgba(0,0,0,.22)&apos;;
    ctx.lineWidth = 0.8;
    roundRect(cx - barW / 2, barY, barW, barH, barH / 2); ctx.stroke();

    // Outlined rather than sitting on a filled pill, so it stays readable over
    // dark walls and pale floor without hiding either.
    var fs = Math.max(7, Math.round(cell * 0.27));
    ctx.font = &apos;bold &apos; + fs + &apos;px sans-serif&apos;;
    ctx.textAlign = &apos;center&apos;;
    ctx.textBaseline = &apos;bottom&apos;;
    var ny = cy - rad - fs * 0.28;
    // Same on the top row: keep the name inside the grid rather than over the
    // words in the gutter.
    if (ny - fs &gt; view.y0) { /* fits above */ }
    else ny = cy + rad + fs * 1.15 + (barY &lt; cy ? 0 : barH * 1.6);
    ctx.lineWidth = Math.max(2, fs * 0.34);
    ctx.strokeStyle = &apos;rgba(251,251,247,.95)&apos;;
    ctx.strokeText(name, cx, ny);
    ctx.fillStyle = mine ? &apos;#8a5000&apos; : color;
    ctx.fillText(name, cx, ny);
    ctx.restore();
  }

  // A face per player, picked deterministically from their id so it is stable
  // for a whole match and different between players. Drawn in the same
  // facing-local frame as the weapon, so everyone looks where they are pointing.
  function faceOf(id) {
    var h = 0;
    for (var i = 0; i &lt; id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 100003;
    return { eyes: h % 3, mouth: Math.floor(h / 3) % 4 };
  }

  function drawFace(cx, cy, rad, angle, v) {
    if (rad &lt; 6) return;               // too small to read as a face
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(angle);
    var ex = rad * 0.24, ey = rad * 0.40, er = rad * 0.20;
    for (var side = -1; side &lt;= 1; side += 2) {
      if (v.eyes === 1) {              // squinting
        ctx.strokeStyle = &apos;rgba(255,255,255,.95)&apos;;
        ctx.lineWidth = Math.max(1.1, rad * 0.13);
        ctx.beginPath();
        ctx.moveTo(ex - er * 0.6, side * ey); ctx.lineTo(ex + er * 0.6, side * ey);
        ctx.stroke();
      } else {
        ctx.fillStyle = &apos;rgba(255,255,255,.95)&apos;;
        ctx.beginPath(); ctx.arc(ex, side * ey, er, 0, 7); ctx.fill();
        ctx.fillStyle = &apos;#222&apos;;
        ctx.beginPath();
        ctx.arc(ex + er * 0.3, side * ey, er * (v.eyes === 2 ? 0.42 : 0.58), 0, 7);
        ctx.fill();
      }
    }
    ctx.strokeStyle = &apos;rgba(255,255,255,.92)&apos;;
    ctx.lineWidth = Math.max(1.1, rad * 0.12);
    var mx = rad * 0.56;
    ctx.beginPath();
    if (v.mouth === 0) { ctx.moveTo(mx, -rad * 0.15); ctx.lineTo(mx, rad * 0.15); }
    else if (v.mouth === 1) { ctx.arc(mx - rad * 0.12, 0, rad * 0.26, -Math.PI * 0.45, Math.PI * 0.45); }
    else if (v.mouth === 2) { ctx.arc(mx, 0, rad * 0.15, 0, 7); }
    else { ctx.arc(mx + rad * 0.22, 0, rad * 0.26, Math.PI * 0.55, Math.PI * 1.45); }
    ctx.stroke();
    ctx.restore();
  }

  // A plain colored dot reads as nothing in particular. Each class gets a
  // weapon silhouette drawn in local +x (facing) coordinates, then rotated to
  // the tracked facing direction — the same drawing works for all four.
  // `t` runs 0..1 through a swing, or -1 when idle. Each weapon moves the way
  // it ought to: the dagger and the wand jab straight out and back, the sword
  // sweeps through its arc, and the bow draws back before the shot leaves.
  function drawAccessory(cls, cx, cy, r, angle, t) {
    var swinging = t &gt;= 0;
    var punch = swinging ? Math.sin(t * Math.PI) : 0;          // out and back
    var sweep = swinging ? (t - 0.5) * 1.9 : 0;                // through an arc
    var draw = swinging ? (t &lt; 0.45 ? -t / 0.45 : -(1 - t) / 0.55) : 0;  // back, then loose
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(angle);
    if (cls === &apos;fighter&apos;) ctx.rotate(sweep);
    if (cls === &apos;rogue&apos; || cls === &apos;mage&apos;) ctx.translate(r * 0.75 * punch, 0);
    if (cls === &apos;ranger&apos;) ctx.translate(r * 0.85 * draw, 0);
    if (cls === &apos;rogue&apos;) {
      ctx.strokeStyle = &apos;#ddd&apos;; ctx.lineWidth = Math.max(1.5, r * .12);
      ctx.beginPath(); ctx.moveTo(r * .9, 0); ctx.lineTo(r * 1.9, 0); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(r * .95, -r * .3); ctx.lineTo(r * .95, r * .3); ctx.stroke();
      ctx.fillStyle = &apos;#ddd&apos;;
      ctx.beginPath(); ctx.moveTo(r * 1.9, 0); ctx.lineTo(r * 2.25, -r * .2); ctx.lineTo(r * 2.25, r * .2); ctx.closePath(); ctx.fill();
    } else if (cls === &apos;fighter&apos;) {
      ctx.strokeStyle = &apos;#ccc&apos;; ctx.lineWidth = Math.max(2, r * .16);
      ctx.beginPath(); ctx.moveTo(r * .8, 0); ctx.lineTo(r * 2.6, 0); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(r * 1.15, -r * .38); ctx.lineTo(r * 1.15, r * .38); ctx.stroke();
      ctx.fillStyle = &apos;#ccc&apos;;
      ctx.beginPath(); ctx.arc(r * .65, 0, r * .16, 0, 7); ctx.fill();
    } else if (cls === &apos;ranger&apos;) {
      ctx.strokeStyle = &apos;#a0713c&apos;; ctx.lineWidth = Math.max(1.5, r * .12);
      ctx.beginPath(); ctx.arc(0, 0, r * 1.15, Math.PI * .6, Math.PI * 1.4); ctx.stroke();
      ctx.strokeStyle = &apos;#654321&apos;; ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(-r * .9, 0); ctx.lineTo(r * 1.7, 0);
      ctx.moveTo(-r * .9, 0); ctx.lineTo(-r * 1.15, -r * .18);
      ctx.moveTo(-r * .9, 0); ctx.lineTo(-r * 1.15, r * .18);
      ctx.stroke();
      ctx.fillStyle = &apos;#654321&apos;;
      ctx.beginPath(); ctx.moveTo(r * 1.7, 0); ctx.lineTo(r * 1.4, -r * .18); ctx.lineTo(r * 1.4, r * .18); ctx.closePath(); ctx.fill();
    } else if (cls === &apos;mage&apos;) {
      ctx.strokeStyle = &apos;#7a5c2e&apos;; ctx.lineWidth = Math.max(1.5, r * .12);
      ctx.beginPath(); ctx.moveTo(r * .9, 0); ctx.lineTo(r * 2.1, 0); ctx.stroke();
      ctx.fillStyle = &apos;#9b59b6&apos;;
      ctx.beginPath(); ctx.arc(r * 2.1, 0, r * .28, 0, 7); ctx.fill();
      ctx.strokeStyle = &apos;#d2b4de&apos;; ctx.lineWidth = 1;
      [-1, 1].forEach(function (s) {
        ctx.beginPath(); ctx.moveTo(r * 2.1, s * r * .3); ctx.lineTo(r * 2.1, s * r * .5); ctx.stroke();
      });
    }
    ctx.restore();
  }

  // Each side of the gutter carries its pair: the move word on the outside,
  // the attack word just inside it against the grid edge — the same order on
  // all four sides, so &quot;outer ring = walk, inner ring = swing&quot; holds however
  // you look at it. The special goes along the bottom, under its pair.
  function drawLegend() {
    if (!myWords) return;
    var buf = typedBuf.trim().toLowerCase();
    [[&apos;moveUp&apos;, &apos;atkUp&apos;, &apos;top&apos;], [&apos;moveDown&apos;, &apos;atkDown&apos;, &apos;bottom&apos;],
     [&apos;moveLeft&apos;, &apos;atkLeft&apos;, &apos;left&apos;], [&apos;moveRight&apos;, &apos;atkRight&apos;, &apos;right&apos;]].forEach(function (t) {
      drawWordPill(myWords[t[0]], buf, t[2], 1, false, &apos;move&apos;);
      drawWordPill(myWords[t[1]], buf, t[2], 0, false, &apos;atk&apos;);
    });
    drawWordPill(myWords.special, buf, &apos;special&apos;, 0, mySpecialReadyAt &gt; Date.now(), &apos;special&apos;);
  }

  // The firable word is drawn full size in its colour; the two queued behind it
  // trail smaller and greyer, close enough to read at a glance while you are
  // still typing the first. Reading ahead is the point — it is what makes a
  // three-letter word actually cheaper than a six-letter one.
  function tailPresent(list) { return !!(list &amp;&amp; list.length); }

  function drawWordPill(queue, buf, side, offset, faded, kind) {
    if (!queue || !queue.length) return;
    var word = queue[0];
    if (!word) return;
    var matchLen = 0;
    while (matchLen &lt; word.length &amp;&amp; matchLen &lt; buf.length &amp;&amp;
           word[matchLen].toLowerCase() === buf[matchLen]) matchLen++;
    var mismatched = buf.length &gt; matchLen;

    var v = view, h = v.pill, g = v.grid;
    var dist = h * (0.62 + offset * 1.13);
    var x, y, rot = 0;
    if (side === &apos;top&apos;) { x = v.x0 + g / 2; y = v.y0 - dist; }
    else if (side === &apos;bottom&apos;) { x = v.x0 + g / 2; y = v.y0 + g + dist; }
    else if (side === &apos;special&apos;) { x = v.x0 + g / 2; y = v.y0 + g + h * 2.95; }
    else if (side === &apos;left&apos;) { x = v.x0 - dist; y = v.y0 + g / 2; rot = -Math.PI / 2; }
    else { x = v.x0 + g + dist; y = v.y0 + g / 2; rot = Math.PI / 2; }

    var mainPx = Math.round(h * 0.72);
    var queuePx = Math.max(7, Math.round(h * 0.5));
    var mainFont = &apos;bold &apos; + mainPx + &apos;px monospace&apos;;
    var queueFont = queuePx + &apos;px monospace&apos;;
    var upcoming = queue.slice(1).filter(Boolean);

    ctx.save();
    ctx.translate(x, y);
    if (rot) ctx.rotate(rot);
    ctx.textBaseline = &apos;middle&apos;;
    ctx.textAlign = &apos;left&apos;;

    // Centre the whole run — word plus its tail — on the gutter line.
    ctx.font = mainFont;
    var mainW = ctx.measureText(word).width;
    ctx.font = queueFont;
    // Space the tail off in pixels rather than with space characters, so the
    // gap does not depend on how the font treats runs of whitespace.
    var gap = tailPresent(upcoming) ? mainPx * 0.45 : 0;
    var tail = upcoming.join(&apos;   &apos;);
    var tailW = tail ? ctx.measureText(tail).width + gap : 0;
    var startX = -(mainW + tailW) / 2;

    var baseColor = faded ? &apos;#bbb&apos; : kind === &apos;move&apos; ? &apos;#2980b9&apos; : kind === &apos;atk&apos; ? &apos;#c0392b&apos; : &apos;#222&apos;;
    ctx.font = mainFont;
    if (matchLen &gt; 0 &amp;&amp; !mismatched) {
      var head = word.slice(0, matchLen), rest = word.slice(matchLen);
      ctx.fillStyle = faded ? &apos;#999&apos; : &apos;#2e8b57&apos;;
      ctx.fillText(head, startX, 1);
      var headW = ctx.measureText(head).width;
      ctx.fillStyle = baseColor;
      ctx.fillText(rest, startX + headW, 1);
    } else {
      ctx.fillStyle = baseColor;
      ctx.fillText(word, startX, 1);
    }

    if (tail) {
      ctx.font = queueFont;
      ctx.fillStyle = faded ? &apos;rgba(180,180,180,.6)&apos; : &apos;rgba(120,120,120,.65)&apos;;
      ctx.fillText(tail, startX + mainW + gap, 1);
    }
    ctx.restore();
  }

  function roundRect(x, y, w, h, r) {
    ctx.beginPath();
    ctx.moveTo(x + r, y);
    ctx.arcTo(x + w, y, x + w, y + h, r);
    ctx.arcTo(x + w, y + h, x, y + h, r);
    ctx.arcTo(x, y + h, x, y, r);
    ctx.arcTo(x, y, x + w, y, r);
    ctx.closePath();
  }
  requestAnimationFrame(draw);

  // ---- menu / room list ---------------------------------------------------
  function refreshRooms() {
    roomsEl.hidden = false;
    roomsEl.innerHTML = &apos;&lt;div&gt;looking for rooms…&lt;/div&gt;&apos;;
    api(&apos;/lobby/list&apos;).then(function (data) {
      var rooms = (data.rooms || []).slice(0, 12);
      if (!rooms.length) { roomsEl.innerHTML = &apos;&lt;div&gt;no rooms yet — create one?&lt;/div&gt;&apos;; return; }
      return Promise.all(rooms.map(function (r) {
        return pingRoom(r.code).then(function (ms) { r.ping = ms; return r; });
      })).then(function (list) {
        list.sort(function (a, b) { return a.ping - b.ping; });
        roomsEl.innerHTML = &apos;&apos;;
        list.forEach(function (r) {
          var row = document.createElement(&apos;div&apos;);
          row.innerHTML = &apos;&lt;span&gt;&apos; + (r.private ? &apos;🔒 &apos; : &apos;&apos;) + r.code + &apos; · &apos; + escapeHtml(r.name) + &apos;&lt;/span&gt;&apos; +
            &apos;&lt;span&gt;&apos; + r.players + &apos;/&apos; + r.max + &apos; · &apos; + (r.ping &gt; 3000 ? &apos;—&apos; : r.ping + &apos;ms&apos;) + &apos;&lt;/span&gt;&apos;;
          row.addEventListener(&apos;click&apos;, function () {
            if (r.players &gt;= r.max) { noteEl.textContent = &apos;That room is full.&apos;; return; }
            var pw = r.private ? (window.prompt(&apos;Password for room &apos; + r.code) || &apos;&apos;) : &apos;&apos;;
            if (r.private &amp;&amp; !pw) return;
            connect(r.code, pw);
          });
          roomsEl.appendChild(row);
        });
      });
    }).catch(function () { roomsEl.innerHTML = &apos;&lt;div&gt;the server is not answering.&lt;/div&gt;&apos;; });
  }
  refreshBtn.addEventListener(&apos;click&apos;, refreshRooms);
  document.getElementById(&apos;wf-vsbot&apos;).addEventListener(&apos;click&apos;, openBotLobby);

  if (!NET_URL) {
    createBtn.disabled = true;
    noteEl.textContent = &apos;Online rooms are off — no server is configured.&apos;;
  } else {
    createBtn.addEventListener(&apos;click&apos;, function () {
      var rn = window.prompt(&apos;Room name:&apos;, (nameEl.value || &apos;our&apos;) + &quot;&apos;s room&quot;);
      if (rn === null) return;
      var pw = window.prompt(&apos;Password (leave blank for a public room):&apos;, &apos;&apos;) || &apos;&apos;;
      noteEl.textContent = &apos;Creating room…&apos;;
      api(&apos;/lobby/create&apos;, {
        method: &apos;POST&apos;, headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify({ name: rn, password: pw, mapSize: myMapSize })
      }).then(function (res) {
        if (res.error) { noteEl.textContent = res.error; return; }
        connect(res.code, pw);
      }).catch(function () { noteEl.textContent = &apos;Could not create the room.&apos;; });
    });
  }

  // Fullscreen is worth it on a phone, where the arena is competing with the
  // browser chrome for height. iOS Safari has no Element.requestFullscreen at
  // all, so the button hides itself rather than pretending.
  var fullBtn = document.getElementById(&apos;wf-full&apos;);
  var reqFull = stage.requestFullscreen || stage.webkitRequestFullscreen;
  fullBtn.hidden = false;          // there is always a way to go big now
  fullBtn.addEventListener(&apos;click&apos;, function () {
    var inFull = document.fullscreenElement || document.webkitFullscreenElement;
    if (inFull) {
      var exit = document.exitFullscreen || document.webkitExitFullscreen;
      if (exit) exit.call(document);
      return;
    }
    if (stage.classList.contains(&apos;immersive&apos;)) { setImmersive(false); return; }
    if (reqFull) { try { reqFull.call(stage); return; } catch (e) {} }
    setImmersive(true);            // iPhone lands here
  });
  function setImmersive(on) {
    stage.classList.toggle(&apos;immersive&apos;, on);
    try { document.body.style.overflow = on ? &apos;hidden&apos; : &apos;&apos;; } catch (e) {}
    fullBtn.textContent = on ? &apos;⤢ exit&apos; : &apos;⛶ full&apos;;
    resizeCanvas(true);
  }
  function onFullChange() {
    var inFull = !!(document.fullscreenElement || document.webkitFullscreenElement);
    fullBtn.textContent = inFull ? &apos;⤢ exit&apos; : &apos;⛶ full&apos;;
    resizeCanvas(true);
  }
  document.addEventListener(&apos;fullscreenchange&apos;, onFullChange);
  document.addEventListener(&apos;webkitfullscreenchange&apos;, onFullChange);

  exitBtn.addEventListener(&apos;click&apos;, function () {
    leaving = true;
    joined = null;
    joinKey = &apos;&apos;;
    reconnectNote = &apos;&apos;;
    inviteBtn.hidden = true;
    inviteUrlEl.hidden = true;
    if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
    if (ws) { try { ws.close(); } catch (e) {} ws = null; }
    if (botMode) { stopBots(); botMode = false; botWorld = null; }
    if (countdownTimer) clearInterval(countdownTimer);
    showScreen(&apos;menu&apos;);
    noteEl.textContent = &apos;&apos;;
  });

  // Put every screen into a known state rather than trusting the markup&apos;s
  // own defaults to match: the arena, the input and the keyboard all used to
  // stay on show under the menu because nothing had hidden them yet.
  renderClassInfo();
  showScreen(&apos;menu&apos;);

  // Someone handed us a link: join that room directly, password skipped by the
  // key it carries.
  (function joinFromLink() {
    var q = String(window.location.search || &apos;&apos;);
    var code = (/[?&amp;]room=([A-Z0-9]{4,8})/i.exec(q) || [])[1];
    if (!code) return;
    var key = (/[?&amp;]key=([^&amp;]+)/.exec(q) || [])[1] || &apos;&apos;;
    noteEl.textContent = &apos;Joining &apos; + code.toUpperCase() + &apos;…&apos;;
    connect(code.toUpperCase(), &apos;&apos;, &apos;&apos;, decodeURIComponent(key));
  })();
})();
&lt;/script&gt;

</content>
 </entry>
 
 <entry>
   <title>Stick Fight</title>
   <link href="https://ashura.id/stick-fight"/>
   <updated>2026-08-12T00:00:00+00:00</updated>
   <id>https://ashura.id/stick-fight</id>
   <content type="html">&lt;style&gt;
/* Mobile first: this is played with two thumbs on a phone, so the phone layout
   is the base and the desktop tweaks are the exception. The arena takes whatever
   height is left after the controls, and the controls never leave the screen. */
#sf-stage{position:relative;left:50%;transform:translateX(-50%);width:calc(100vw - 8px);max-width:1100px;margin:0 0 6px;}
@supports (height: 100dvh) {
  #sf-stage{width:min(calc(100vw - 8px), calc((100dvh - 336px) * 1.777), 1100px);}
}
@supports not (height: 100dvh) {
  #sf-stage{width:min(calc(100vw - 8px), calc((100vh - 336px) * 1.777), 1100px);}
}
#sf-wrap{position:relative;}
/* Weapon bar: one fixed-height line above the arena. It reports what you are
   holding and lets you throw it away. Fixed height because anything above the
   canvas that can wrap will shove the whole map down a line mid-fight. */
#sf-gear{display:flex;align-items:center;gap:8px;height:38px;margin:0 2px 5px;padding:0 9px;
  border:1.5px solid #ddd;border-radius:9px;background:#fbfbf7;font-size:13px;line-height:1;
  white-space:nowrap;overflow:hidden;}
#sf-gear[hidden]{display:none;}
#sf-gear-what{font-weight:bold;}
#sf-gear-dmg,#sf-gear-ammo{color:#777;font-size:12px;}
#sf-drop{margin-left:auto;font:inherit;font-size:12px;font-weight:bold;padding:7px 11px;
  border:1.5px solid #222;border-radius:8px;background:#fbfbf7;color:#222;cursor:pointer;flex:0 0 auto;}
#sf-drop:disabled{opacity:.35;cursor:not-allowed;border-color:#bbb;}

/* Fixed height, and it never wraps: a strip above the arena that grows from one
   line to two shoves the map (and the controls) down mid-fight. Too many
   fighters to fit just scroll sideways. */
#sf-score{display:flex;flex-wrap:nowrap;align-items:center;gap:6px;height:34px;
  margin:0 2px 4px;padding:0 1px;font-size:13px;line-height:1;
  overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;scrollbar-width:none;}
#sf-score::-webkit-scrollbar{display:none;}
#sf-score[hidden]{display:none;}
#sf-score .who{display:flex;align-items:center;gap:5px;flex:0 0 auto;padding:6px 8px;
  border:1.5px solid #ddd;border-radius:8px;background:#fbfbf7;white-space:nowrap;}
#sf-score .nm{max-width:8ch;overflow:hidden;text-overflow:ellipsis;}
#sf-score .who.self{border-color:#222;font-weight:bold;}
#sf-score .dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;}
#sf-score .k{font-weight:bold;font-variant-numeric:tabular-nums;}
#sf-score .goal{margin-left:auto;flex:0 0 auto;color:#999;font-size:11px;white-space:nowrap;padding-right:2px;}
#sf-canvas{display:block;width:100%;height:auto;background:#fbfbf7;border:2px solid #222;border-radius:8px;cursor:crosshair;touch-action:none;user-select:none;-webkit-user-select:none;}
#sf-wrap.with-menu{min-height:min(74vh,470px);}

#sf-menu{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:11px;background:rgba(251,251,247,.96);border:2px solid #222;border-radius:8px;font-family:inherit;overflow:auto;padding:14px 0;}
#sf-menu[hidden]{display:none;}   /* display:flex above outranks the browser&apos;s own */
#sf-menu h2{margin:0;font-size:clamp(19px,5.5vw,28px);}
#sf-menu p{margin:0;font-size:13px;color:#777;text-align:center;max-width:34em;padding:0 16px;}
#sf-menu .row{display:flex;gap:9px;flex-wrap:wrap;justify-content:center;padding:0 12px;}
#sf-menu input{font:inherit;font-size:16px;padding:11px 13px;border:1.5px solid #ccc;border-radius:9px;background:#fff;min-width:140px;}
#sf-menu button{font:inherit;font-size:16px;font-weight:bold;padding:13px 20px;border:2px solid #222;border-radius:10px;background:#222;color:#fbfbf7;cursor:pointer;}
#sf-menu button.ghost{background:#fbfbf7;color:#222;}
#sf-menu button:disabled{opacity:.4;cursor:not-allowed;}
#sf-rooms{width:min(440px,94%);max-height:min(34vh,220px);overflow:auto;font-size:15px;}
#sf-rooms div{display:flex;justify-content:space-between;gap:8px;padding:12px 11px;border:1px dashed #ccc;border-radius:8px;margin-bottom:6px;cursor:pointer;}
#sf-rooms div:active{background:#eee;}
#sf-note{font-size:12px;color:#999;text-align:center;padding:0 12px;}

/* The between-rounds panel. Same furniture as the menu, over the frozen arena
   rather than instead of it: the last moment of the fight stays visible behind
   the scores, which is most of the fun of losing. */
#sf-room{position:absolute;inset:0;z-index:6;display:flex;flex-direction:column;align-items:center;
  justify-content:center;gap:10px;background:rgba(251,251,247,.93);border-radius:8px;padding:14px 0;overflow:auto;}
#sf-room[hidden]{display:none;}
#sf-room h3{margin:0;font-size:clamp(17px,4.6vw,23px);}
#sf-room .sub{margin:0;font-size:12px;color:#888;text-align:center;padding:0 14px;}
#sf-final{width:min(400px,92%);font-size:15px;}
#sf-final .r{display:flex;align-items:center;gap:7px;padding:9px 11px;border:1px dashed #ccc;
  border-radius:8px;margin-bottom:5px;}
#sf-final .r.win{border:2px solid #222;font-weight:bold;}
#sf-final .r.away{opacity:.45;}
#sf-final .dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;}
#sf-final .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
#sf-final .k{font-variant-numeric:tabular-nums;}
#sf-final .st{font-size:12px;color:#888;min-width:5.5em;text-align:right;}
#sf-room .row{display:flex;gap:9px;flex-wrap:wrap;justify-content:center;padding:0 12px;}
#sf-room button{font:inherit;font-size:16px;font-weight:bold;padding:12px 18px;border:2px solid #222;
  border-radius:10px;background:#222;color:#fbfbf7;cursor:pointer;}
#sf-room button.ghost{background:#fbfbf7;color:#222;}
#sf-room input{font:inherit;font-size:16px;padding:10px 12px;border:1.5px solid #ccc;border-radius:9px;
  background:#fff;min-width:140px;}
#sf-countdown{font-size:14px;font-weight:bold;min-height:1.2em;}

/* The invite sits over the arena next to the menu button, not in the round-over
   panel: you want to hand the link out before the fight, not after it. */
#sf-invite{position:absolute;top:7px;left:7px;z-index:7;font:inherit;font-size:12px;font-weight:bold;
  padding:8px 11px;border:1.5px solid #bbb;border-radius:8px;background:rgba(251,251,247,.92);
  color:#555;cursor:pointer;}
#sf-invite-url{position:absolute;top:44px;left:7px;z-index:7;width:min(320px,70%);font:inherit;
  font-size:12px;padding:7px 9px;border:1.5px solid #bbb;border-radius:8px;background:#fff;}
#sf-invite-url[hidden],#sf-invite[hidden]{display:none;}
#sf-exit{position:absolute;top:7px;right:7px;z-index:5;font:inherit;font-size:12px;font-weight:bold;padding:8px 11px;border:1.5px solid #bbb;border-radius:8px;background:rgba(251,251,247,.92);color:#555;cursor:pointer;}
#sf-exit[hidden]{display:none;}

/* Controls: thumbs at the outer edges, but inset far enough that the analog is
   not jammed against the side of the screen. */
#sf-controls{display:none;justify-content:space-between;align-items:center;gap:10px;
  padding:0 8px 0 4px;margin:8px 0 16px;touch-action:none;user-select:none;-webkit-user-select:none;}
#sf-controls.on{display:flex;}
#sf-pad{display:grid;grid-template-columns:repeat(3,54px);grid-template-rows:repeat(3,46px);gap:5px;touch-action:none;}
#sf-pad .sf-btn{pointer-events:none;}   /* the pad surface owns the gesture */
#sf-pad .up{grid-area:1/2/2/3;}
#sf-pad .lf{grid-area:2/1/3/2;}
#sf-pad .rt{grid-area:2/3/3/4;}
#sf-pad .dn{grid-area:3/2/4/3;}
.sf-btn{font:inherit;font-weight:bold;font-size:19px;line-height:1;border:2px solid #222;border-radius:13px;background:#fbfbf7;color:#222;cursor:pointer;touch-action:none;display:flex;align-items:center;justify-content:center;padding:0;}
.sf-btn.down{background:#222;color:#fbfbf7;}
#sf-aimwrap{display:flex;flex-direction:column;align-items:center;gap:6px;padding-right:6px;}
#sf-special{font:inherit;font-size:13px;font-weight:bold;letter-spacing:.03em;width:132px;height:42px;
  border:2px solid #222;border-radius:11px;background:#fbfbf7;color:#222;cursor:pointer;touch-action:none;}
#sf-special:disabled{opacity:.32;cursor:not-allowed;border-color:#bbb;}
#sf-special.down{background:#222;color:#fbfbf7;}
#sf-aimpad{width:132px;height:132px;border:2px solid #222;border-radius:50%;background:#fbfbf7;touch-action:none;cursor:grab;}
#sf-aimpad.down{background:#f1f1e9;cursor:grabbing;}

#sf-opts{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;font-size:13px;color:#555;margin:0 0 12px;}
#sf-opts label{display:flex;align-items:center;gap:7px;cursor:pointer;font-weight:bold;color:#222;}
#sf-opts input{width:17px;height:17px;accent-color:#222;cursor:pointer;}
#sf-lagcomp-note{font-size:11px;color:#999;}
#sf-lagsim{font-size:11px;font-weight:bold;color:#c0392b;}
#sf-lagsim[hidden]{display:none;}
#sf-help{font-size:12px;color:#777;margin:0 0 18px;line-height:1.6;}
#sf-cta{font-size:14px;line-height:1.6;color:#555;margin:0 0 26px;padding:14px 16px;
  border:1.5px dashed #ccc;border-radius:10px;background:#fbfbf7;}
#sf-cta b{color:#222;}
#sf-help b{color:#222;}

/* Narrow phones: shrink a little rather than overflowing */
@media (max-width:400px){
  #sf-pad{grid-template-columns:repeat(3,48px);grid-template-rows:repeat(3,42px);}
  #sf-aimpad{width:116px;height:116px;}
  #sf-special{width:116px;height:38px;font-size:12px;}
  .sf-btn{font-size:19px;border-radius:11px;}
}
/* Desktop: keyboard is the real control, so the pads step back */
@media (min-width:760px){
  #sf-pad{grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,40px);}
  #sf-aimpad{width:112px;height:112px;}
  #sf-special{width:112px;}
  #sf-controls{opacity:.85;}
}
&lt;/style&gt;

&lt;div id=&quot;sf-stage&quot;&gt;
&lt;div id=&quot;sf-score&quot; hidden=&quot;&quot;&gt;&lt;/div&gt;
&lt;div id=&quot;sf-gear&quot; hidden=&quot;&quot;&gt;
  &lt;span id=&quot;sf-gear-what&quot;&gt;✊ FISTS&lt;/span&gt;
  &lt;span id=&quot;sf-gear-dmg&quot;&gt;&lt;/span&gt;
  &lt;span id=&quot;sf-gear-ammo&quot;&gt;&lt;/span&gt;
  &lt;button type=&quot;button&quot; id=&quot;sf-drop&quot; disabled=&quot;&quot;&gt;drop&lt;/button&gt;
&lt;/div&gt;
&lt;div id=&quot;sf-wrap&quot; class=&quot;with-menu&quot;&gt;
  &lt;canvas id=&quot;sf-canvas&quot; width=&quot;960&quot; height=&quot;540&quot;&gt;&lt;/canvas&gt;
  &lt;button id=&quot;sf-exit&quot; hidden=&quot;&quot; title=&quot;back to the menu (Esc)&quot;&gt;✕ menu&lt;/button&gt;
  &lt;div id=&quot;sf-room&quot; hidden=&quot;&quot;&gt;
    &lt;h3 id=&quot;sf-room-title&quot;&gt;Round over&lt;/h3&gt;
    &lt;div id=&quot;sf-final&quot;&gt;&lt;/div&gt;
    &lt;div id=&quot;sf-countdown&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;row&quot;&gt;
      &lt;button id=&quot;sf-ready&quot;&gt;Ready&lt;/button&gt;
      &lt;input id=&quot;sf-roomname&quot; maxlength=&quot;12&quot; placeholder=&quot;your name&quot; /&gt;
    &lt;/div&gt;
    &lt;p class=&quot;sub&quot; id=&quot;sf-room-sub&quot;&gt;&lt;/p&gt;
  &lt;/div&gt;
  &lt;!-- Outside the round-over panel on purpose. Inside it, the link could only be
       copied once somebody had already won, which is the opposite of when you
       want to invite people. --&gt;
  &lt;button id=&quot;sf-invite&quot; hidden=&quot;&quot; title=&quot;copy a link that drops a friend straight into this room&quot;&gt;🔗 invite&lt;/button&gt;
  &lt;input id=&quot;sf-invite-url&quot; hidden=&quot;&quot; readonly=&quot;&quot; /&gt;
  &lt;div id=&quot;sf-menu&quot;&gt;
    &lt;h2&gt;🥢 STICK FIGHT&lt;/h2&gt;
    &lt;p id=&quot;sf-tagline&quot;&gt;Ragdoll stickmen. The sword shoves hard, the gun reaches far, and the pit forgives nothing.&lt;/p&gt;
    &lt;div class=&quot;row&quot;&gt;
      &lt;input id=&quot;sf-name&quot; maxlength=&quot;12&quot; placeholder=&quot;your name&quot; /&gt;
      &lt;button id=&quot;sf-solo&quot;&gt;Play vs bots&lt;/button&gt;
    &lt;/div&gt;
    &lt;div class=&quot;row&quot;&gt;
      &lt;button id=&quot;sf-create&quot; class=&quot;ghost&quot;&gt;Create room&lt;/button&gt;
      &lt;button id=&quot;sf-join&quot; class=&quot;ghost&quot;&gt;Join room&lt;/button&gt;
    &lt;/div&gt;
    &lt;p id=&quot;sf-note&quot;&gt;&lt;/p&gt;
    &lt;div id=&quot;sf-rooms&quot; hidden=&quot;&quot;&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div id=&quot;sf-controls&quot;&gt;
  &lt;div id=&quot;sf-pad&quot;&gt;
    &lt;button class=&quot;sf-btn up&quot; data-k=&quot;jump&quot;&gt;▲&lt;/button&gt;
    &lt;button class=&quot;sf-btn lf&quot; data-k=&quot;l&quot;&gt;◀&lt;/button&gt;
    &lt;button class=&quot;sf-btn rt&quot; data-k=&quot;r&quot;&gt;▶&lt;/button&gt;
    &lt;button class=&quot;sf-btn dn&quot; data-k=&quot;duck&quot;&gt;▼&lt;/button&gt;
  &lt;/div&gt;
  &lt;div id=&quot;sf-aimwrap&quot;&gt;
    &lt;canvas id=&quot;sf-aimpad&quot; width=&quot;256&quot; height=&quot;256&quot; title=&quot;drag to aim, let go to attack&quot;&gt;&lt;/canvas&gt;
    &lt;button type=&quot;button&quot; id=&quot;sf-special&quot; disabled=&quot;&quot;&gt;✷ &lt;span&gt;SPECIAL&lt;/span&gt;&lt;/button&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;

&lt;p id=&quot;sf-opts&quot;&gt;
  &lt;label&gt;&lt;input type=&quot;checkbox&quot; id=&quot;sf-lagcomp&quot; /&gt; client-side lag compensation&lt;/label&gt;
  &lt;span id=&quot;sf-lagsim&quot; hidden=&quot;&quot;&gt;&lt;/span&gt;
  &lt;span id=&quot;sf-lagcomp-note&quot;&gt;turn on and your own stickman answers instantly, with the server correcting it — off, every move waits for the round trip&lt;/span&gt;
&lt;/p&gt;

&lt;p id=&quot;sf-help&quot;&gt;
  &lt;b&gt;Keyboard&lt;/b&gt; — A/D move · W jump · S crouch, or drop through a platform when one is below you · arrows or mouse aim · space attacks · E special · Q drops your weapon.&lt;br /&gt;
  &lt;b&gt;Touch&lt;/b&gt; — d-pad on the left; the ring on the right aims, and lifting your finger attacks.&lt;br /&gt;
  Weapons fall onto the map over time; walk into one to take it. Each has its own reach, damage and shove, and a special that uses it up — the bar above the arena tells you what you are holding.
&lt;/p&gt;

&lt;p id=&quot;sf-cta&quot;&gt;
  &lt;b&gt;Got an idea? Leave a comment below.&lt;/b&gt;&lt;br /&gt;
  New weapons, new specials, a mechanic that ruins everything — suggest it and I will build the good ones.
&lt;/p&gt;

&lt;script src=&quot;/assets/js/stickfight-sim.js&quot;&gt;&lt;/script&gt;

&lt;script src=&quot;/assets/js/stickfight-wire.js&quot;&gt;&lt;/script&gt;

&lt;script&gt;
(function () {
  var S = window.StickSim;
  var cv = document.getElementById(&apos;sf-canvas&apos;);
  var menu = document.getElementById(&apos;sf-menu&apos;);
  var note = document.getElementById(&apos;sf-note&apos;);
  var roomsEl = document.getElementById(&apos;sf-rooms&apos;);
  var nameEl = document.getElementById(&apos;sf-name&apos;);
  var roomPanel = document.getElementById(&apos;sf-room&apos;);
  var roomTitle = document.getElementById(&apos;sf-room-title&apos;);
  var roomSub = document.getElementById(&apos;sf-room-sub&apos;);
  var finalEl = document.getElementById(&apos;sf-final&apos;);
  var countdownEl = document.getElementById(&apos;sf-countdown&apos;);
  var readyBtn = document.getElementById(&apos;sf-ready&apos;);
  var roomNameEl = document.getElementById(&apos;sf-roomname&apos;);
  var inviteBtn = document.getElementById(&apos;sf-invite&apos;);
  var inviteUrlEl = document.getElementById(&apos;sf-invite-url&apos;);
  var wrap = document.getElementById(&apos;sf-wrap&apos;);
  var controls = document.getElementById(&apos;sf-controls&apos;);
  var ap = document.getElementById(&apos;sf-aimpad&apos;);
  if (!S || !cv || !cv.getContext) { if (menu) menu.innerHTML = &apos;&lt;p&gt;This device cannot run the game.&lt;/p&gt;&apos;; return; }
  var ctx = cv.getContext(&apos;2d&apos;);
  if (!ctx) { menu.innerHTML = &apos;&lt;p&gt;Canvas is not available.&lt;/p&gt;&apos;; return; }

  // The room server: a Worker with one Durable Object per room code.
  // See server/ in the repo. Set sf_server in localStorage to point at a local
  // `wrangler dev` instead, without editing the post.
  var NET_URL = &apos;https://stickfight.hashshura.workers.dev&apos;;
  try { NET_URL = localStorage.getItem(&apos;sf_server&apos;) || NET_URL; } catch (e) {}

  var INK = &apos;#222&apos;, PAPER = &apos;#fbfbf7&apos;;
  var TEAM = [&apos;#222&apos;, &apos;#c0392b&apos;, &apos;#2e7d32&apos;, &apos;#3b7ea1&apos;, &apos;#e0a800&apos;, &apos;#7d3c98&apos;];
  var KILLS_TO_WIN = 10;   // the room may override this on join

  var world = null, me = null, mode = &apos;menu&apos;, bots = [], winner = null;
  var scale = 1, camY = 0;

  // The room&apos;s own state, as distinct from the fight&apos;s: which phase we are in
  // between rounds, whether we have pressed ready, and the two secrets that let
  // us back in — the invite key for the room and the token for our own seat.
  var phase = &apos;fighting&apos;, iAmReady = false, finalTable = null;
  var roomCode = &apos;&apos;, roomKey = &apos;&apos;, seatToken = &apos;&apos;, roomInvite = &apos;&apos;;
  // Reconnect bookkeeping. `leaving` separates a close we asked for from one the
  // network handed us, so pressing &quot;menu&quot; does not start a reconnect loop.
  var lastPassword = &apos;&apos;, retries = 0, retryTimer = null, leaving = false;

  try { nameEl.value = localStorage.getItem(&apos;sf_name&apos;) || &apos;&apos;; } catch (e) {}
  roomNameEl.value = nameEl.value;

  function sizeCanvas() {
    var wCss = cv.clientWidth || 960;
    cv.width = Math.round(Math.min(1200, Math.max(280, wCss)));
    cv.height = Math.round(cv.width * S.H / S.W);
    scale = cv.width / S.W;
  }

  // ---- input ---------------------------------------------------------------
  var keys = {};
  var mouse = { cx: 0, cy: 0, has: false };
  var pad = { l: 0, r: 0, jump: 0, duck: 0, aim: null, fire: 0, discard: 0, special: 0 };  // touch
  var touches = {};

  window.addEventListener(&apos;keydown&apos;, function (e) {
    if (mode === &apos;menu&apos;) return;
    var k = e.key.toLowerCase();
    if ([&apos;a&apos;,&apos;d&apos;,&apos;w&apos;,&apos;s&apos;,&apos;q&apos;,&apos;e&apos;,&apos; &apos;,&apos;arrowleft&apos;,&apos;arrowright&apos;,&apos;arrowup&apos;,&apos;arrowdown&apos;].indexOf(k) &gt;= 0) e.preventDefault();
    keys[k] = 1;
  });
  window.addEventListener(&apos;keyup&apos;, function (e) { keys[e.key.toLowerCase()] = 0; });

  cv.addEventListener(&apos;mousemove&apos;, function (e) {
    var r = cv.getBoundingClientRect();
    mouse.cx = (e.clientX - r.left) / r.width * cv.width;
    mouse.cy = (e.clientY - r.top) / r.height * cv.height;
    mouse.has = true;
  });
  cv.addEventListener(&apos;mousedown&apos;, function (e) { e.preventDefault(); keys[&apos; &apos;] = 1; });
  window.addEventListener(&apos;mouseup&apos;, function () { keys[&apos; &apos;] = 0; });

  // Touching the arena aims there and swings, which is what clicking does with a
  // mouse. It did nothing on a phone before: the mouse handlers above are the only
  // path to the aim, and a tap does not produce a mousemove to set it.
  function aimAtPoint(clientX, clientY) {
    var r = cv.getBoundingClientRect();
    mouse.cx = (clientX - r.left) / r.width * cv.width;
    mouse.cy = (clientY - r.top) / r.height * cv.height;
    mouse.has = true;
    pad.aim = null;      // the tap takes aiming over, exactly as a mouse would
  }
  cv.addEventListener(&apos;pointerdown&apos;, function (e) {
    if (e.pointerType === &apos;mouse&apos;) return;          // handled above
    if (e.cancelable) e.preventDefault();           // and no synthetic click after
    aimAtPoint(e.clientX, e.clientY);
    pad.fire = 4;                                   // the same one-shot the ring uses
  });
  cv.addEventListener(&apos;pointermove&apos;, function (e) {
    if (e.pointerType === &apos;mouse&apos; || !e.buttons) return;
    if (e.cancelable) e.preventDefault();
    aimAtPoint(e.clientX, e.clientY);               // drag to re-aim, without swinging
  });

  // Controls are real buttons under the arena rather than zones drawn inside it.
  // Drawn-on-canvas pads were tiny on a phone and sat on top of the fight.
  var apCtx = ap &amp;&amp; ap.getContext ? ap.getContext(&apos;2d&apos;) : null;
  var padDrag = null;

  // The d-pad is one continuous surface, not four separate buttons. Your thumb
  // lands on ◀ and slides up to ▲ and the direction follows it, corners give you
  // both at once. As four independent buttons, sliding off one left you pressing
  // nothing at all, which is what made the movement feel dead.
  var padEl = document.getElementById(&apos;sf-pad&apos;);
  var padBtns = {};
  if (padEl &amp;&amp; padEl.querySelectorAll) {
    var all = padEl.querySelectorAll(&apos;.sf-btn&apos;);
    for (var bi = 0; bi &lt; all.length; bi++) {
      padBtns[all[bi].getAttribute(&apos;data-k&apos;)] = all[bi];
    }
    var mark = function () {
      for (var k in padBtns) {
        if (pad[k]) padBtns[k].classList.add(&apos;down&apos;);
        else padBtns[k].classList.remove(&apos;down&apos;);
      }
    };
    var setDir = function (e) {
      var r = padEl.getBoundingClientRect();
      var dx = (e.clientX - r.left) - r.width / 2;
      var dy = (e.clientY - r.top) - r.height / 2;
      // The pad captures the pointer, so a thumb that slides off it keeps
      // steering — drift a little above the pad while your other hand attacks and
      // &quot;up&quot; stays held, which reads as the stickman jumping on its own. Let go
      // once the finger has clearly left the pad.
      if (Math.abs(dx) &gt; r.width * 0.85 || Math.abs(dy) &gt; r.height * 0.85) {
        clearDir();
        return;
      }
      var dead = Math.min(r.width, r.height) * 0.15;
      pad.l = dx &lt; -dead ? 1 : 0;
      pad.r = dx &gt; dead ? 1 : 0;
      pad.jump = dy &lt; -dead ? 1 : 0;
      pad.duck = dy &gt; dead ? 1 : 0;
      mark();
    };
    var clearDir;   // hoisted: setDir bails out through it
    clearDir = function () { pad.l = pad.r = pad.jump = pad.duck = 0; mark(); };
    padEl.addEventListener(&apos;pointerdown&apos;, function (e) {
      e.preventDefault();
      if (padEl.setPointerCapture &amp;&amp; e.pointerId != null) {
        try { padEl.setPointerCapture(e.pointerId); } catch (err) {}
      }
      setDir(e);
    });
    padEl.addEventListener(&apos;pointermove&apos;, function (e) {
      if (e.pointerType === &apos;mouse&apos; &amp;&amp; !e.buttons) return;   // only while held down
      if (e.cancelable) e.preventDefault();                  // never scroll the page
      setDir(e);
    });
    padEl.addEventListener(&apos;pointerup&apos;, clearDir);
    padEl.addEventListener(&apos;pointercancel&apos;, clearDir);
    padEl.addEventListener(&apos;lostpointercapture&apos;, clearDir);
  }

  // The aim pad: drag to point the weapon, and lifting your finger attacks in
  // whatever direction you left it pointing. A plain tap attacks along the
  // current aim. One thumb, one gesture, no separate fire button to reach for.
  function aimFrom(e) {
    var r = ap.getBoundingClientRect();
    var dx = (e.clientX - r.left) - r.width / 2;
    var dy = (e.clientY - r.top) - r.height / 2;
    if (dx * dx + dy * dy &gt; 100) {
      pad.aim = Math.atan2(dy, dx);
      if (padDrag) padDrag.moved = 1;
    }
  }
  if (ap) {
    ap.addEventListener(&apos;pointerdown&apos;, function (e) {
      e.preventDefault();
      padDrag = { moved: 0, t0: Date.now() };
      ap.classList.add(&apos;down&apos;);
      if (ap.setPointerCapture &amp;&amp; e.pointerId != null) {
        try { ap.setPointerCapture(e.pointerId); } catch (err) {}
      }
      aimFrom(e);
    });
    ap.addEventListener(&apos;pointermove&apos;, function (e) {
      if (!padDrag) return;
      if (e.cancelable) e.preventDefault();
      aimFrom(e);
    });
    var endPad = function () {
      if (padDrag) pad.fire = 4;        // release = attack, tap or drag alike
      padDrag = null;
      ap.classList.remove(&apos;down&apos;);
    };
    ap.addEventListener(&apos;pointerup&apos;, endPad);
    ap.addEventListener(&apos;pointercancel&apos;, endPad);
  }

  // The whole arena is always on screen, at the same framing for everybody. A
  // phone that zoomed in to make the fighters readable would also see less of the
  // map than a laptop — that is a competitive disadvantage, not a comfort. The
  // arena is portrait and small instead, so the figures are big without hiding
  // anything.
  var cam = { x: S.W / 2, y: S.H / 2, z: 1 };
  function updateCamera() { cam.z = 1; cam.x = S.W / 2; cam.y = S.H / 2; }
  function screenToWorld(cx, cy) {
    var eff = scale * cam.z;
    return { x: (cx - cv.width / 2) / eff + cam.x, y: (cy - cv.height / 2) / eff + cam.y };
  }

  function gatherInput(p) {
    var i = p.input;
    i.l = (keys[&apos;a&apos;] || pad.l) ? 1 : 0;
    i.r = (keys[&apos;d&apos;] || pad.r) ? 1 : 0;
    i.jump = keys[&apos;w&apos;] || pad.jump ? 1 : 0;
    i.duck = keys[&apos;s&apos;] || pad.duck ? 1 : 0;

    // aim: arrow keys act like a stick, else the mouse, else the touch ring
    var ax = (keys[&apos;arrowright&apos;] ? 1 : 0) - (keys[&apos;arrowleft&apos;] ? 1 : 0);
    var ay = (keys[&apos;arrowdown&apos;] ? 1 : 0) - (keys[&apos;arrowup&apos;] ? 1 : 0);
    if (ax || ay) i.aim = Math.atan2(ay, ax);
    else if (pad.aim !== null) i.aim = pad.aim;
    else if (mouse.has) {
      var c = p.pts[S.CHEST];
      var m = screenToWorld(mouse.cx, mouse.cy);
      i.aim = Math.atan2(m.y - c.y, m.x - c.x);
    }
    i.fire = (keys[&apos; &apos;] || pad.fire &gt; 0) ? 1 : 0;
    if (pad.fire &gt; 0) pad.fire--;
    i.discard = (keys[&apos;q&apos;] || pad.discard &gt; 0) ? 1 : 0;
    if (pad.discard &gt; 0) pad.discard--;
    i.special = (keys[&apos;e&apos;] || pad.special &gt; 0) ? 1 : 0;
    if (pad.special &gt; 0) pad.special--;
    // latch it: the sender samples every 50ms and a tap can be shorter than that
    if (i.fire &amp;&amp; typeof pendingFire !== &apos;undefined&apos;) pendingFire = true;
  }

  // ---- bots ----------------------------------------------------------------
  // Enough to be a nuisance: chase, keep roughly the right distance for whatever
  // they are holding, grab crates when unarmed, and aim with a wobble so they
  // miss like a person does.
  function botThink(w, b, tick) {
    var i = b.input, hips = b.pts[S.HIPS], chest = b.pts[S.CHEST];
    i.l = i.r = i.jump = i.duck = i.fire = 0;
    if (b.dead) return;

    // nearest living enemy
    var foe = null, best = 1e9;
    for (var id in w.players) {
      var q = w.players[id];
      if (q === b || q.dead) continue;
      var d = Math.abs(q.pts[S.HIPS].x - hips.x) + Math.abs(q.pts[S.HIPS].y - hips.y) * 1.3;
      if (d &lt; best) { best = d; foe = q; }
    }

    // Unarmed bots go shopping. Crates are worth crossing the map for: a fist
    // does 7 damage and a sword does 22 with a shove.
    var goal = null, goalIsCrate = false;
    if (b.weapon === &apos;fist&apos; &amp;&amp; w.pickups.length) {
      var crate = null, cb = 1e9;
      for (var k = 0; k &lt; w.pickups.length; k++) {
        var pk = w.pickups[k];
        var dd = Math.abs(pk.x - hips.x) + Math.abs(pk.y - hips.y) * 1.3;
        if (dd &lt; cb) { cb = dd; crate = pk; }
      }
      if (crate) { goal = { x: crate.x, y: crate.y }; goalIsCrate = true; }
    }
    if (!goal &amp;&amp; foe) goal = { x: foe.pts[S.CHEST].x, y: foe.pts[S.CHEST].y };
    if (!goal) {                      // nothing to do: patrol a random ledge
      var pl = w.platforms[(b.color + Math.floor(tick / 240)) % w.platforms.length];
      goal = { x: pl.x + pl.w / 2, y: pl.y - 20 };
    }

    // Keep the distance the weapon wants, but never just stand there: the old
    // deadband was wider than the approach step, so bots idled in place.
    var want = goalIsCrate ? 0 : (b.weapon === &apos;gun&apos; ? 190 : 26);
    var dx = goal.x - hips.x;
    var adx = Math.abs(dx);
    if (adx &gt; want + 14) { if (dx &gt; 0) i.r = 1; else i.l = 1; }
    else if (adx &lt; want - 14) { if (dx &gt; 0) i.l = 1; else i.r = 1; }
    else if ((tick + b.color * 40) % 150 &lt; 40) { i[dx &gt; 0 ? &apos;l&apos; : &apos;r&apos;] = 1; }  // circle a bit

    // Climb toward anything above, and hop over anything in the way.
    var dy = goal.y - hips.y;
    if (dy &lt; -40 &amp;&amp; b.grounded &amp;&amp; (tick + b.color * 17) % 26 &lt; 10) i.jump = 1;
    b._px = b._px === undefined ? hips.x : b._px;
    b._stuck = (Math.abs(hips.x - b._px) &lt; 0.6 &amp;&amp; (i.l || i.r)) ? (b._stuck || 0) + 1 : 0;
    b._px = hips.x;
    if (b._stuck &gt; 20) { i.jump = 1; b._stuck = 0; }        // shove yourself loose
    if (b.grounded &amp;&amp; hips.y &gt; S.H - 90 &amp;&amp; dy &lt; -20) i.jump = 1;   // climb off the floor

    // duck now and then when someone is shooting from roughly your height
    if (foe &amp;&amp; foe.weapon === &apos;gun&apos; &amp;&amp; Math.abs(foe.pts[S.CHEST].y - chest.y) &lt; 30 &amp;&amp;
        (tick + b.color * 31) % 120 &lt; 22) i.duck = 1;

    if (!foe) return;
    var fx = foe.pts[S.CHEST].x - chest.x, fy = foe.pts[S.CHEST].y - chest.y;
    i.aim = Math.atan2(fy, fx) + Math.sin(tick * 0.05 + b.color) * 0.18;   // hand wobble
    var dist = Math.sqrt(fx * fx + fy * fy);
    var reach = b.weapon === &apos;gun&apos; ? 620 : S.WEAPONS[b.weapon].reach + 14;
    if (dist &lt; reach &amp;&amp; b.cd === 0) i.fire = 1;
  }

  // ---- drawing -------------------------------------------------------------
  // in online mode a point is drawn between the last two snapshots
  var lerpA = 1;
  var drawOffset = null, drawIdx = 0;
  // One offset for the whole body, applied to every point equally — a correction
  // that moves the joints by different amounts draws the stickman stretched.
  function px(q) {
    var v = lerpA &gt;= 1 ? q.x : q.ox + (q.x - q.ox) * lerpA;
    if (drawOffset) v += drawOffset.x;
    q.dx = v;                     // remember where it was actually drawn
    return v;
  }
  function py(q) {
    var v = lerpA &gt;= 1 ? q.y : q.oy + (q.y - q.oy) * lerpA;
    if (drawOffset) v += drawOffset.y;
    q.dy = v;
    return v;
  }

  function ink(w, c) { ctx.strokeStyle = c || INK; ctx.lineWidth = w || 2.4; ctx.lineJoin = &apos;round&apos;; ctx.lineCap = &apos;round&apos;; }
  function txt(s, x, y, size, color, bold, align) {
    ctx.save();
    ctx.textAlign = align || &apos;center&apos;;
    ctx.fillStyle = color || INK;
    ctx.font = (bold ? &apos;bold &apos; : &apos;&apos;) + Math.round(size || 13) + &apos;px sans-serif&apos;;
    ctx.fillText(s, x, y);
    ctx.restore();
  }

  function drawArena(w) {
    ctx.fillStyle = PAPER;
    ctx.fillRect(-40, -40, S.W + 80, S.H + 120);
    // the void: hatching below the lowest deck, so the danger reads
    ctx.save();
    ctx.globalAlpha = 0.16;
    ink(1.6);
    for (var x = -60; x &lt; S.W + 60; x += 20) {
      ctx.beginPath();
      ctx.moveTo(x, S.H); ctx.lineTo(x + 52, S.H - 58);
      ctx.stroke();
    }
    ctx.restore();
    for (var i = 0; i &lt; w.platforms.length; i++) {
      var pl = w.platforms[i];
      ink(2.6);
      ctx.beginPath();
      ctx.moveTo(pl.x, pl.y); ctx.lineTo(pl.x + pl.w, pl.y);
      ctx.stroke();
      ink(1.6);
      for (var d = 0; d &lt; pl.w; d += 16) {
        ctx.beginPath();
        ctx.moveTo(pl.x + d, pl.y + 2); ctx.lineTo(pl.x + d + 9, pl.y + pl.h);
        ctx.stroke();
      }
    }
  }

  function drawCrate(pk) {
    ink(2);
    ctx.save();
    ctx.translate(pk.x, pk.y - 14);
    if (pk.kind === &apos;sword&apos;) {
      ctx.beginPath(); ctx.moveTo(-9, 9); ctx.lineTo(9, -9); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(2, -2); ctx.lineTo(10, -12); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-2, 6); ctx.lineTo(4, 12); ctx.stroke();
    } else {
      ctx.beginPath(); ctx.moveTo(-10, -3); ctx.lineTo(9, -3); ctx.lineTo(9, 3); ctx.lineTo(-2, 3); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-2, 3); ctx.lineTo(-4, 10); ctx.stroke();
    }
    ctx.restore();
    ink(1.4, &apos;#aaa&apos;);
    ctx.beginPath(); ctx.arc(pk.x, pk.y - 14, 19, 0, 7); ctx.stroke();
  }

  function drawWeapon(p) {
    var chest = { x: px(p.pts[S.CHEST]), y: py(p.pts[S.CHEST]) };
    var hand = { x: px(p.pts[S.HANDR]), y: py(p.pts[S.HANDR]) };
    var a = Math.atan2(hand.y - chest.y, hand.x - chest.x);
    ctx.save();
    ctx.translate(hand.x, hand.y);
    ctx.rotate(a);
    ink(2.4, TEAM[p.color % TEAM.length]);
    if (p.weapon === &apos;sword&apos;) {
      ctx.beginPath(); ctx.moveTo(-2, 0); ctx.lineTo(30, 0); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(0, -5); ctx.lineTo(0, 5); ctx.stroke();
    } else if (p.weapon === &apos;gun&apos;) {
      ctx.beginPath(); ctx.moveTo(0, -2); ctx.lineTo(16, -2); ctx.lineTo(16, 2); ctx.lineTo(4, 2); ctx.closePath(); ctx.stroke();
    }
    ctx.restore();
  }

  function drawPlayer(p) {
    var col = TEAM[p.color % TEAM.length];
    var pts = p.pts;
    // a corpse thrown into the void keeps tumbling; stop drawing it once it is
    // past the hatching rather than smearing it down off the canvas
    if (py(pts[S.HIPS]) &gt; S.H + 24) return;
    ctx.save();
    if (p.dead) ctx.globalAlpha = 0.35;
    ink(p.id === (me &amp;&amp; me.id) ? 3 : 2.4, col);
    for (var i = 0; i &lt; S.BONES.length; i++) {
      var b = S.BONES[i];
      ctx.beginPath();
      ctx.moveTo(px(pts[b[0]]), py(pts[b[0]]));
      ctx.lineTo(px(pts[b[1]]), py(pts[b[1]]));
      ctx.stroke();
    }
    var h = { x: px(pts[S.HEAD]), y: py(pts[S.HEAD]) };
    ctx.beginPath(); ctx.arc(h.x, h.y, 8, 0, 7); ctx.stroke();
    if (p.dead) {            // X eyes, the universal signal
      ink(1.6, col);
      ctx.beginPath();
      ctx.moveTo(h.x - 5, h.y - 3); ctx.lineTo(h.x - 1, h.y + 1);
      ctx.moveTo(h.x - 1, h.y - 3); ctx.lineTo(h.x - 5, h.y + 1);
      ctx.moveTo(h.x + 1, h.y - 3); ctx.lineTo(h.x + 5, h.y + 1);
      ctx.moveTo(h.x + 5, h.y - 3); ctx.lineTo(h.x + 1, h.y + 1);
      ctx.stroke();
    }
    ctx.restore();
    if (p.dead) return;
    drawWeapon(p);

    // health bar and name above the head
    var bx = h.x - 17, by = h.y - 22;
    ctx.fillStyle = &apos;#ddd&apos;; ctx.fillRect(bx, by, 34, 4);
    ctx.fillStyle = p.hp &gt; 50 ? &apos;#2e7d32&apos; : (p.hp &gt; 22 ? &apos;#e0a800&apos; : &apos;#c0392b&apos;);
    ctx.fillRect(bx, by, 34 * Math.max(0, p.hp) / S.MAX_HP, 4);
    ink(1, &apos;#999&apos;); ctx.strokeRect(bx, by, 34, 4);
    txt(p.name, h.x, by - 5, 10, &apos;#666&apos;, p.id === (me &amp;&amp; me.id));
  }

  function drawFx(w) {
    for (var i = 0; i &lt; w.fx.length; i++) {
      var f = w.fx[i];
      if (f.k === &apos;slash&apos;) {
        ctx.save();
        ctx.globalAlpha = Math.min(1, f.t / 9);
        ink(f.big ? 3.4 : 2, f.big ? &apos;#222&apos; : &apos;#888&apos;);
        ctx.beginPath();
        ctx.arc(f.x, f.y, f.r, f.a - f.arc / 2, f.a + f.arc / 2);
        ctx.stroke();
        ctx.restore();
      } else if (f.k === &apos;spin&apos;) {
        // a full ring, opening outward, with a couple of trailing sweep lines
        ctx.save();
        // clamped: ctx.arc throws on a negative radius, and the effect&apos;s timer is
        // set by the simulation, which may outlive the number assumed here
        var g = Math.max(0, Math.min(1, 1 - f.t / 26));
        ctx.globalAlpha = Math.max(0, Math.min(1, f.t / 26)) * 0.9;
        ink(3.2, &apos;#222&apos;);
        ctx.beginPath();
        ctx.arc(f.x, f.y, Math.max(2, f.r * (0.45 + g * 0.75)), 0, Math.PI * 2);
        ctx.stroke();
        ink(2, &apos;#888&apos;);
        for (var sp = 0; sp &lt; 3; sp++) {
          var sa = f.a + g * Math.PI * 3 - sp * 0.5;
          ctx.beginPath();
          ctx.arc(f.x, f.y, Math.max(2, f.r * 0.95), sa, sa + 0.45);
          ctx.stroke();
        }
        ctx.restore();
      } else if (f.k === &apos;jab&apos;) {
        // a punch has no slice path: just a short impact burst at the knuckles
        ctx.save();
        ctx.globalAlpha = Math.min(1, f.t / 8);
        ink(2.2, &apos;#555&apos;);
        for (var j = 0; j &lt; 3; j++) {
          var ja = f.a + (j - 1) * 0.5;
          ctx.beginPath();
          ctx.moveTo(f.x + Math.cos(ja) * 3, f.y + Math.sin(ja) * 3);
          ctx.lineTo(f.x + Math.cos(ja) * 9, f.y + Math.sin(ja) * 9);
          ctx.stroke();
        }
        ctx.restore();
      } else if (f.k === &apos;hit&apos; || f.k === &apos;die&apos;) {
        ctx.save();
        ctx.globalAlpha = Math.min(1, f.t / (f.k === &apos;die&apos; ? 26 : 14));
        ink(2, &apos;#c0392b&apos;);
        var n = f.k === &apos;die&apos; ? 8 : 5, rad = f.k === &apos;die&apos; ? 22 : 12;
        for (var s = 0; s &lt; n; s++) {
          var a = s / n * Math.PI * 2;
          ctx.beginPath();
          ctx.moveTo(f.x + Math.cos(a) * 4, f.y + Math.sin(a) * 4);
          ctx.lineTo(f.x + Math.cos(a) * rad, f.y + Math.sin(a) * rad);
          ctx.stroke();
        }
        ctx.restore();
      } else if (f.k === &apos;pick&apos;) {
        ctx.save();
        ctx.globalAlpha = Math.min(1, f.t / 16);
        ink(2, &apos;#2e7d32&apos;);
        ctx.beginPath(); ctx.arc(f.x, f.y - 14, 22 - f.t, 0, 7); ctx.stroke();
        ctx.restore();
      }
    }
    for (var b = 0; b &lt; w.bullets.length; b++) {
      var bl = w.bullets[b];
      ink(2.2, &apos;#222&apos;);
      ctx.beginPath();
      ctx.moveTo(bl.x, bl.y);
      ctx.lineTo(bl.x - bl.vx * 0.7, bl.y - bl.vy * 0.7);
      ctx.stroke();
    }
  }

  // Drawn in canvas pixels, not arena units. Scaling it with the arena meant a
  // 390px phone rendered 11px text at about 4px — the scoreboard was unreadable
  // exactly where the screen was smallest.
  // Only things that must sit over the arena are drawn on it now: the respawn
  // countdown and the win banner. The running score is DOM, above the canvas.
  function drawHud(w) {
    var cw = cv.width, ch = cv.height;
    var u = Math.max(13, Math.min(20, cw / 28));
    if (me &amp;&amp; me.dead) {
      txt(&apos;down — respawning in &apos; + Math.ceil(me.respawn / 60) + &apos;s&apos;,
          cw / 2, ch * 0.13, u * 1.4, &apos;#c0392b&apos;, true);
    }
    if (winner) {
      ctx.save();
      ctx.fillStyle = &apos;rgba(251,251,247,.88)&apos;;
      ctx.fillRect(0, ch / 2 - u * 3.4, cw, u * 6.8);
      txt(winner + &apos; wins&apos;, cw / 2, ch / 2 + u * 0.2, u * 2.4, INK, true);
      txt(&apos;click to play again&apos;, cw / 2, ch / 2 + u * 2.4, u, &apos;#666&apos;);
      ctx.restore();
    }
  }

  // Weapon bar. Damage and knockback come from the sim&apos;s own table, so the
  // numbers on screen cannot drift from the ones doing the hitting.
  var gearEl = document.getElementById(&apos;sf-gear&apos;);
  var gearWhat = document.getElementById(&apos;sf-gear-what&apos;);
  var gearDmg = document.getElementById(&apos;sf-gear-dmg&apos;);
  var gearAmmo = document.getElementById(&apos;sf-gear-ammo&apos;);
  var dropBtn = document.getElementById(&apos;sf-drop&apos;);
  var gearLast = &apos;&apos;;
  var GEAR_NAME = { fist: &apos;✊ FISTS&apos;, sword: &apos;⚔ SWORD&apos;, gun: &apos;🔫 GUN&apos; };
  var GEAR_PUSH = { fist: &apos;light shove&apos;, sword: &apos;heavy shove&apos;, gun: &apos;light shove&apos; };
  function drawGear() {
    if (!gearEl || !me) return;
    var wp = S.WEAPONS[me.weapon];
    var sig = me.weapon + &apos;:&apos; + me.ammo;
    if (sig === gearLast) return;
    gearLast = sig;
    gearWhat.textContent = GEAR_NAME[me.weapon] || me.weapon;
    gearDmg.textContent = wp.dmg + &apos; dmg · &apos; + GEAR_PUSH[me.weapon] +
      &apos; · &apos; + (wp.cd / 60).toFixed(2) + &apos;s cooldown&apos;;
    gearAmmo.textContent = wp.ammo ? &apos;· &apos; + me.ammo + &apos; rounds&apos; : &apos;&apos;;
    dropBtn.disabled = me.weapon === &apos;fist&apos;;
    if (specialBtn) {
      // the special spends the weapon, so it is only offered while you hold one
      specialBtn.disabled = me.weapon === &apos;fist&apos;;
      var label = SPECIAL_NAME[me.weapon] || &apos;✷ SPECIAL&apos;;
      if (me.weapon === &apos;gun&apos;) label += &apos; ×&apos; + me.ammo;   // it sprays what is left
      if (specialBtn.textContent !== label) specialBtn.textContent = label;
    }
  }
  if (dropBtn) {
    dropBtn.addEventListener(&apos;click&apos;, function () {
      pad.discard = 3;         // a few frames, so one press is one throw
    });
  }
  var specialBtn = document.getElementById(&apos;sf-special&apos;);
  var SPECIAL_NAME = { sword: &apos;✷ SPIN&apos;, gun: &apos;✷ SPRAY&apos;, fist: &apos;✷ SPECIAL&apos; };
  if (specialBtn) {
    specialBtn.addEventListener(&apos;pointerdown&apos;, function (e) {
      e.preventDefault();
      if (specialBtn.disabled) return;
      pad.special = 3;
      specialBtn.classList.add(&apos;down&apos;);
    });
    var offSpecial = function () { specialBtn.classList.remove(&apos;down&apos;); };
    specialBtn.addEventListener(&apos;pointerup&apos;, offSpecial);
    specialBtn.addEventListener(&apos;pointercancel&apos;, offSpecial);
  }

  // The score strip. Rebuilt only when the line-up changes and retouched only
  // when a number changes, so it is not doing DOM work sixty times a second.
  var scoreEl = document.getElementById(&apos;sf-score&apos;);
  var scoreRows = {}, scoreKey = &apos;&apos;, scoreAt = 0, goalEl = null, goalLast = &apos;&apos;;
  function drawScore(w) {
    if (!scoreEl) return;
    var now = Date.now();
    if (now - scoreAt &lt; 120) return;
    scoreAt = now;
    var ids = Object.keys(w.players);
    ids.sort(function (a, b) { return w.players[b].kills - w.players[a].kills ||
                                      (a &lt; b ? -1 : 1); });
    var key = ids.map(function (id) { return id + &apos;:&apos; + w.players[id].name; }).join(&apos;|&apos;);
    if (key !== scoreKey) {
      scoreKey = key;
      scoreEl.innerHTML = &apos;&apos;;
      scoreRows = {};
      for (var i = 0; i &lt; ids.length; i++) {
        var p = w.players[ids[i]];
        var row = document.createElement(&apos;span&apos;);
        row.className = &apos;who&apos; + (p.id === (me &amp;&amp; me.id) ? &apos; self&apos; : &apos;&apos;);
        var dot = document.createElement(&apos;span&apos;);
        dot.className = &apos;dot&apos;;
        dot.style.background = TEAM[p.color % TEAM.length];
        var nm = document.createElement(&apos;span&apos;);
        nm.className = &apos;nm&apos;;
        nm.textContent = p.name;
        var kills = document.createElement(&apos;span&apos;);
        kills.className = &apos;k&apos;;
        row.appendChild(dot); row.appendChild(nm); row.appendChild(kills);
        scoreEl.appendChild(row);
        scoreRows[p.id] = { kills: kills, last: -1 };
      }
      goalEl = document.createElement(&apos;span&apos;);
      goalEl.className = &apos;goal&apos;;
      goalLast = &apos;&apos;;
      scoreEl.appendChild(goalEl);
    }
    for (var k = 0; k &lt; ids.length; k++) {
      var q = w.players[ids[k]], cell = scoreRows[q.id];
      if (!cell) continue;
      var val = q.kills + (q.dead ? &apos; ☠&apos; : &apos;&apos;);
      if (val !== cell.last) { cell.kills.textContent = val; cell.last = val; }
    }
    // The ping moves while the line-up does not, so this is retouched on its own
    // rather than only when the strip is rebuilt — otherwise it would show
    // whatever the latency was at the moment the last player joined.
    if (goalEl) {
      var txt = &apos;first to &apos; + KILLS_TO_WIN + &apos; kills&apos; +
        (mode === &apos;online&apos; ? (myPing ? &apos; · &apos; + myPing + &apos;ms&apos; : &apos; · online&apos;) : &apos;&apos;);
      if (txt !== goalLast) { goalEl.textContent = txt; goalLast = txt; }
    }
  }

  // The aim pad doubles as the cooldown readout: the ring fills as the weapon
  // comes back, so &quot;can I swing yet&quot; is answered where your thumb already is.
  function drawAimPad() {
    if (!apCtx) return;
    var W2 = ap.width, C = W2 / 2, R = W2 * 0.40;
    apCtx.clearRect(0, 0, W2, W2);
    var wp = me ? S.WEAPONS[me.weapon] : null;
    var frac = (me &amp;&amp; wp &amp;&amp; wp.cd) ? Math.min(1, 1 - me.cd / wp.cd) : 1;
    apCtx.lineWidth = W2 * 0.055;
    apCtx.lineCap = &apos;butt&apos;;
    apCtx.strokeStyle = &apos;#e6e6df&apos;;
    apCtx.beginPath(); apCtx.arc(C, C, R, 0, Math.PI * 2); apCtx.stroke();
    apCtx.strokeStyle = frac &gt;= 1 ? &apos;#2e7d32&apos; : &apos;#c0392b&apos;;
    apCtx.beginPath();
    apCtx.arc(C, C, R, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * frac);
    apCtx.stroke();

    var a = (predMe &amp;&amp; mode === &apos;online&apos;) ? predMe.aim : (me ? me.aim : (pad.aim || 0));
    apCtx.strokeStyle = &apos;#222&apos;;
    apCtx.lineWidth = W2 * 0.045;
    apCtx.lineCap = &apos;round&apos;;
    apCtx.beginPath();
    apCtx.moveTo(C, C);
    apCtx.lineTo(C + Math.cos(a) * R * 0.72, C + Math.sin(a) * R * 0.72);
    apCtx.stroke();
    apCtx.beginPath();
    apCtx.arc(C + Math.cos(a) * R * 0.72, C + Math.sin(a) * R * 0.72, W2 * 0.05, 0, 7);
    apCtx.fillStyle = &apos;#222&apos;; apCtx.fill();

    var label = !me ? &apos;&apos; : (me.weapon === &apos;gun&apos; ? &apos;PISTOL &apos; + me.ammo
      : (me.weapon === &apos;sword&apos; ? &apos;PEDANG&apos; : &apos;TANGAN&apos;));
    apCtx.fillStyle = &apos;#555&apos;;
    apCtx.textAlign = &apos;center&apos;;
    apCtx.font = &apos;bold &apos; + Math.round(W2 * 0.075) + &apos;px sans-serif&apos;;
    apCtx.fillText(label, C, W2 * 0.87);      // inside the circle, not clipped by it
  }

  function render() {
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, cv.width, cv.height);
    // world, seen through the camera
    var eff = scale * cam.z;
    ctx.setTransform(eff, 0, 0, eff, cv.width / 2 - cam.x * eff, cv.height / 2 - cam.y * eff);
    drawArena(world);
    for (var i = 0; i &lt; world.pickups.length; i++) drawCrate(world.pickups[i]);
    for (var id in world.players) {
      // the local body is drawn from the prediction, at full confidence: it is
      // already up to date, so interpolating it would only add lag back
      if (mode === &apos;online&apos; &amp;&amp; predMe &amp;&amp; id === myId) {
        var keep = lerpA; lerpA = 1;
        drawOffset = predOff;
        drawPlayer(predMe);
        drawOffset = null;
        lerpA = keep;
      } else {
        drawPlayer(world.players[id]);
      }
    }
    drawFx(world);
    // hud, in canvas pixels so it stays legible on a small screen
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    drawHud(world);
    drawAimPad();
    drawScore(world);
    drawGear();
  }

  // ---- game loop -----------------------------------------------------------
  var acc = 0, last = 0;
  function frame(now) {
    requestAnimationFrame(frame);
    if (!world) return;
    if (!last) last = now;
    var dt = Math.min(0.25, (now - last) / 1000);
    last = now;
    acc += dt;
    var steps = 0;
    while (acc &gt;= S.DT &amp;&amp; steps &lt; 6) {
      acc -= S.DT; steps++;
      if (mode === &apos;solo&apos;) {
        if (me &amp;&amp; !winner) gatherInput(me);
        for (var b = 0; b &lt; bots.length; b++) botThink(world, bots[b], world.t);
        S.step(world);
        checkWin();
      } else if (mode === &apos;online&apos; &amp;&amp; phase === &apos;over&apos;) {
        // Between rounds the server has stopped stepping, so predicting forward
        // would walk our body away from a world that is no longer moving — and
        // then snap it back the instant the next round&apos;s first snapshot lands.
        continue;
      } else if (mode === &apos;online&apos; &amp;&amp; me) {
        gatherInput(me);
        // The tick counter and the input history number the packets the server
        // acks, so they advance whether or not prediction is running. Freezing
        // them while lag compensation was switched off sent every input under one
        // sequence number, and switching back on replayed a history recorded
        // before the switch: the body lurched up to 77px a frame, unplayable.
        //
        // A corpse is not predicted either. The simulation respawns from its own
        // random state, so a local copy picks a different spawn point than the
        // server and the body flicks between the two.
        predTick = (predTick + 1) &amp; 0xffff;
        // Send on the tick the input changes, and record what the server will be
        // acting on — the sent packet, not the held keys. Recorded even while
        // prediction is off, because the server is applying it regardless, so a
        // replay after the switch reproduces what actually happened. Only a corpse
        // records blanks.
        var sent = sendInput(predTick);
        var corpse = !!(predMe &amp;&amp; predMe.dead);
        // Any tick we do not simulate leaves a hole, and a hole that still holds a
        // matching tick number from 256 ticks ago is worse than no history at all.
        if (corpse || !predMe) stateTick[predTick &amp; 255] = -1;
        inputHist[predTick &amp; 255] = corpse
          // a blank entry, so a replay reaching back across the death — or across
          // the switch — does not re-apply whatever was held down back then
          ? { l: 0, r: 0, jump: 0, duck: 0, fire: 0, discard: 0, special: 0, aim: sent.aim }
          : sent;
        if (corpse || !predMe) continue;
        predMe.input = inputHist[predTick &amp; 255];
        S.step(predWorld);
        recordState(predTick);
      }
    }
    updateCamera();
    predOffsetDecay();
    // Snapshots land at 20Hz, so draw part-way between the last two rather than
    // stepping 50ms at a time. decodeSnapshot leaves the previous position in
    // ox/oy, which is exactly the &quot;from&quot; this needs.
    lerpA = mode === &apos;online&apos;
      ? Math.max(0, Math.min(1, ((now - snapAt) || 0) / snapGap))
      : 1;
    try { render(); } catch (e) { /* never let a draw glitch kill the loop */ }
  }

  function checkWin() {
    if (winner) return;
    for (var id in world.players) {
      if (world.players[id].kills &gt;= KILLS_TO_WIN) { winner = world.players[id].name; return; }
    }
  }

  function startSolo() {
    var name = (nameEl.value || &apos;you&apos;).slice(0, 12);
    try { localStorage.setItem(&apos;sf_name&apos;, name); } catch (e) {}
    world = S.createWorld(Date.now() % 100000);   // new arena every match
    me = S.addPlayer(world, &apos;me&apos;, name, 0);
    bots = [];
    var botNames = [&apos;rex&apos;, &apos;nyx&apos;, &apos;vex&apos;];
    for (var i = 0; i &lt; 3; i++) bots.push(S.addPlayer(world, &apos;b&apos; + i, botNames[i], i + 1));
    winner = null;
    mode = &apos;solo&apos;;
    showGame();
    last = 0; acc = 0;
  }

  var exitBtn = document.getElementById(&apos;sf-exit&apos;);
  function showGame() {
    menu.hidden = true;
    wrap.classList.remove(&apos;with-menu&apos;);
    controls.classList.add(&apos;on&apos;);
    if (exitBtn) exitBtn.hidden = false;
    if (scoreEl) { scoreEl.hidden = false; scoreKey = &apos;&apos;; }
    if (gearEl) { gearEl.hidden = false; gearLast = &apos;&apos;; }
  }
  function showMenu() {
    menu.hidden = false;
    wrap.classList.add(&apos;with-menu&apos;);
    controls.classList.remove(&apos;on&apos;);
    if (exitBtn) exitBtn.hidden = true;
    if (scoreEl) scoreEl.hidden = true;
    if (gearEl) gearEl.hidden = true;
  }
  function leaveMatch() {
    if (mode === &apos;menu&apos;) return;
    // Deliberate: give up the seat rather than reconnecting into it, and drop the
    // token so a later join arrives as a new player with a clean score.
    leaving = true;
    seatToken = &apos;&apos;;
    if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
    if (ws) { try { ws.close(); } catch (e) {} ws = null; }
    mode = &apos;menu&apos;;
    me = null; bots = []; winner = null;
    predWorld = null; predMe = null;
    phase = &apos;fighting&apos;; iAmReady = false; finalTable = null;
    roomPanel.hidden = true;
    inviteBtn.hidden = true;
    inviteUrlEl.hidden = true;
    stopCountdown();
    pad.l = pad.r = pad.jump = pad.duck = 0;
    showMenu();
    note.textContent = &apos;&apos;;
  }
  if (exitBtn) exitBtn.addEventListener(&apos;click&apos;, leaveMatch);
  window.addEventListener(&apos;keydown&apos;, function (e) {
    if (e.key === &apos;Escape&apos;) leaveMatch();
  });

  document.getElementById(&apos;sf-solo&apos;).addEventListener(&apos;click&apos;, startSolo);

  cv.addEventListener(&apos;click&apos;, function () {
    if (winner) startSolo();
  });

  // ---- local prediction ----------------------------------------------------
  // Without this, your own stickman does not move until your input has been to
  // the server and back: measured at 48ms from next door, and whatever the
  // distance costs from further away. The host feels nothing; everyone else feels
  // all of it. So the local body is simulated here as well, and the server&apos;s
  // version quietly corrects it.
  //
  // It works because a body&apos;s motion depends only on its own input, the
  // platforms, and gravity — players do not collide with each other. What cannot
  // be predicted is what other people do to you, so a hit still lands a round
  // trip late; that is the honest limit of this without rollback.
  var predWorld = null, predMe = null, predBad = 0;
  var HIPS_I = S.HIPS;
  // Per-tick history, so a correction can be replayed rather than guessed: the
  // input we held on each tick, and the body it produced.
  var predTick = 0, inputHist = new Array(256), sentAt = new Array(256);
  var stateHist = new Array(256), stateTick = new Array(256);
  var rttEst = 80, predOff = null;
  var SNAP_TICKS = 2;             // the server broadcasts every other tick
  // Visible in the console as window.sfStats, so a player who sees the body
  // misbehave can say which build and which numbers.
  var sfStats = { build: &apos;predict-4&apos;, rtt: 0, err: 0, errMax: 0, agree: 0, fix: 0, snap: 0, resync: 0, gap: 0 };
  window.sfStats = sfStats;

  // The round trip, measured on the live socket: the server echoes back the input
  // sequence it last applied, and our sequence numbers are our own tick numbers,
  // so the age of the packet that ack names is the round trip. `held` counts the
  // ticks the server has been re-using that input for want of a newer one — those
  // are ticks of our silence, not of travel, so only a fresh ack measures anything.
  //
  // This lives out here rather than inside predSync because it is not a prediction
  // concern. It ran in there for a while, which meant switching lag compensation
  // off froze the readout at whatever it happened to say, and the number on screen
  // then quietly described a connection from several minutes ago.
  function measureRtt(src) {
    if (!src) return;
    var t0 = sentAt[(src.ack || 0) &amp; 255];
    if (!t0 || (src.held || 0) &gt; 1) return;
    rttEst = Math.max(0, Math.min(900, rttEst * 0.7 + Math.min(900, Date.now() - t0) * 0.3));
    sfStats.rtt = Math.round(rttEst);
    // What the HUD shows. The menu measured an HTTP round trip through the Worker
    // to a possibly-sleeping room; this is the socket the fight actually runs on.
    myPing = sfStats.rtt;
  }

  function recordState(t) {
    var a = stateHist[t &amp; 255];
    if (!a) a = stateHist[t &amp; 255] = new Float64Array(predMe.pts.length * 4);
    for (var i = 0; i &lt; predMe.pts.length; i++) {
      var q = predMe.pts[i];
      a[i * 4] = q.x; a[i * 4 + 1] = q.y; a[i * 4 + 2] = q.ox; a[i * 4 + 3] = q.oy;
    }
    stateTick[t &amp; 255] = t;
  }
  // Off unless asked for, and remembered per browser once it has been touched.
  // Flip PREDICT_DEFAULT to turn it on for everyone.
  //
  // Tried on, reverted: it reads as worse, not better. Waiting a round trip is at
  // least honest — the body is late but it is never wrong. Prediction trades that
  // for a body that is on time and occasionally wrong, and a correction you can
  // see is more distracting than a delay you have stopped noticing. Worth another
  // go only once sfStats.snap is rare enough that the corrections stop showing.
  var PREDICT_DEFAULT = false;
  var PREDICT = PREDICT_DEFAULT;
  try {
    var saved = localStorage.getItem(&apos;sf_predict&apos;);
    if (saved !== null) PREDICT = saved !== &apos;0&apos;;
  } catch (e) {}
  var lagBox = document.getElementById(&apos;sf-lagcomp&apos;);
  var lagSim = document.getElementById(&apos;sf-lagsim&apos;);
  if (lagBox) {
    lagBox.checked = PREDICT;
    lagBox.addEventListener(&apos;change&apos;, function () {
      PREDICT = lagBox.checked;
      try { localStorage.setItem(&apos;sf_predict&apos;, PREDICT ? &apos;1&apos; : &apos;0&apos;); } catch (e) {}
      if (!PREDICT) { predWorld = null; predMe = null; }     // fall back to snapshots
      else if (mode === &apos;online&apos; &amp;&amp; me) predStart();
    });
  }

  function predStart() {
    if (!PREDICT || !world || !myId || !world.players[myId]) return;
    predWorld = S.createWorld(mapSeed || 1);
    predMe = S.addPlayer(predWorld, &apos;local&apos;, &apos;me&apos;, 0);
    for (var pi = 0; pi &lt; predMe.pts.length; pi++) predMe.pts[pi]._pi = pi;
    predOff = null;
    // Nothing from a previous prediction session survives. The tick ring holds 256
    // entries, so walking around for four seconds with lag compensation switched
    // off lines an old entry up with a current tick number — and a snapshot then
    // &quot;agrees&quot; with a body that was somewhere else entirely, leaving the real
    // disagreement to be corrected over and over. On flat ground, with nobody
    // pressing jump, that draws as a stickman bouncing.
    for (var si = 0; si &lt; stateTick.length; si++) stateTick[si] = -1;
    // Start exactly where the body is on screen, so switching the switch does not
    // move it. predSync below still snaps to the server&apos;s version, but it measures
    // the correction from here and eases it in, instead of from a spawn pose.
    // And clear the draw coordinates left on the snapshot body from when it was the
    // one being drawn, or the interpolation rewrite above treats them as current.
    var mineNow = world.players[myId];
    for (var di = 0; di &lt; mineNow.pts.length; di++) {
      var qd = predMe.pts[di], bd = mineNow.pts[di];
      qd.x = bd.dx !== undefined ? bd.dx : bd.x;
      qd.y = bd.dy !== undefined ? bd.dy : bd.y;
      qd.ox = qd.x - (bd.x - bd.ox) / SNAP_TICKS;
      qd.oy = qd.y - (bd.y - bd.oy) / SNAP_TICKS;
      delete bd.dx; delete bd.dy;
    }
    predSync(world.players[myId], true);
  }

  function predSync(src, hard) {
    if (!predMe || !src) return;
    predMe.hp = src.hp; predMe.kills = src.kills; predMe.deaths = src.deaths;
    predMe.name = src.name; predMe.color = src.color;
    predMe.weapon = src.weapon; predMe.ammo = src.ammo; predMe.cd = src.cd;
    predMe.spin = src.spin; predMe.spray = src.spray;
    // Reconciliation by comparison, then replay.
    //
    // The snapshot is the truth about a moment that has passed, so it cannot be
    // dropped on top of a body that has since moved. But it also cannot be used to
    // re-derive the body from scratch every 33ms: sizing that extrapolation needs
    // a clock, every clock here is an estimate, and an estimate that wobbles moves
    // the body — which is what rubber banding is. Two separate attempts at
    // estimating the round trip both wobbled, and both were felt as unplayable.
    //
    // So no extrapolation. There is an exact correspondence available instead: the
    // server applied our input sequence `ack` and has re-used it for `held` ticks
    // since, and our sequence numbers *are* our tick numbers — so this snapshot
    // describes our own tick ack+held. We kept what we predicted for that tick. If
    // the two agree, the prediction was right and the body is left completely
    // alone: nothing to smooth, nothing to yank. Only a real disagreement — a hit,
    // a shove, a pickup, anything the local body could not know about — rewinds to
    // the server&apos;s version and replays our inputs forward from there.
    var now = Date.now();
    var errSeen = -1;
    var at = ((src.ack || 0) + (src.held || 0)) &amp; 0xffff;   // the tick this describes
    var gap = (predTick - at) &amp; 0xffff;                     // how far we have run since
    // If the tick it describes is *ahead* of ours, our own clock stalled — a
    // background tab, a long frame, a garbage collection — and the subtraction has
    // wrapped. Re-anchor instead of limping along with no usable history: that was
    // measured as a three-second run of blind corrections on a real link.
    if (gap &gt; 1000) { predTick = at; gap = 0; sfStats.resync++; }
    sfStats.gap = gap;
    var mine = (gap &lt;= 150 &amp;&amp; stateTick[at &amp; 255] === at) ? stateHist[at &amp; 255] : null;

    if (mine &amp;&amp; !hard &amp;&amp; !src.dead &amp;&amp; !predMe.dead) {
      var err = 0;
      for (var e0 = 0; e0 &lt; src.pts.length; e0++) {
        var d0 = Math.abs(mine[e0 * 4] - src.pts[e0].x) + Math.abs(mine[e0 * 4 + 1] - src.pts[e0].y);
        if (d0 &gt; err) err = d0;
      }
      sfStats.err = Math.round(err);
      if (err &gt; sfStats.errMax) {
        sfStats.errMax = Math.round(err);
        // Is the disagreement the whole body being somewhere else (a timing or
        // correspondence problem) or the body being a different shape (a physics
        // divergence)? They need different fixes, so measure which.
        var mx = 0, my = 0;
        for (var m0 = 0; m0 &lt; src.pts.length; m0++) {
          mx += src.pts[m0].x - mine[m0 * 4]; my += src.pts[m0].y - mine[m0 * 4 + 1];
        }
        mx /= src.pts.length; my /= src.pts.length;
        var shape = 0;
        for (var m1 = 0; m1 &lt; src.pts.length; m1++) {
          shape = Math.max(shape, Math.abs((src.pts[m1].x - mine[m1 * 4]) - mx) +
                                  Math.abs((src.pts[m1].y - mine[m1 * 4 + 1]) - my));
        }
        sfStats.errMove = Math.round(Math.abs(mx) + Math.abs(my));
        sfStats.errShape = Math.round(shape);
        sfStats.errGap = gap;
      }
      // Snapshot coordinates are whole pixels, and the correspondence can sit a
      // tick or two out when the server&apos;s loop and ours drift, which costs a tick
      // of travel — so the tolerance grows with speed. Being forgiving is safe:
      // an error that is real keeps growing, and the next snapshot catches it.
      var sp = Math.abs(predMe.pts[HIPS_I].x - predMe.pts[HIPS_I].ox) +
               Math.abs(predMe.pts[HIPS_I].y - predMe.pts[HIPS_I].oy);
      errSeen = err;
      if (err &lt;= Math.min(18, 4 + sp * 2)) { sfStats.agree++; return; }
      sfStats.fix++;
    } else if (!hard) {
      sfStats.snap++;
    }

    // Remember where the body is being *drawn* — which includes any correction
    // still being eased out. Measuring from the simulation position instead would
    // reset the smoothing on every snapshot, so the per-frame cap below would
    // never get to do its job and a knockback correction arrived in two frames.
    var wasX = [], wasY = [];
    for (var w0 = 0; w0 &lt; predMe.pts.length; w0++) {
      wasX.push(predMe.pts[w0].x + (predOff ? predOff.x : 0));
      wasY.push(predMe.pts[w0].y + (predOff ? predOff.y : 0));
    }

    var wasDead = predMe.dead;
    for (var c0 = 0; c0 &lt; src.pts.length; c0++) {
      var a0 = predMe.pts[c0], b0 = src.pts[c0];
      // Position from the server; velocity from whichever of us knows better.
      //
      // The wire carries no velocity, and a decoded snapshot&apos;s ox/oy is the
      // PREVIOUS snapshot&apos;s position — two ticks of travel, not one — so copying it
      // in hands the body about twice its real speed and every correction
      // overshoots. Dividing it by the snapshot interval gives a usable two-tick
      // average, and that is the right source when something happened we could not
      // have known about: a shove is mostly velocity, and ours would be wrong.
      // When we agree with the server, though, our own one-tick velocity is exact,
      // and an averaged one would smear the body&apos;s motion.
      var trustMine = mine &amp;&amp; errSeen &gt;= 0 &amp;&amp; errSeen &lt; 14;
      var vx0 = trustMine ? mine[c0 * 4] - mine[c0 * 4 + 2] : (b0.x - b0.ox) / SNAP_TICKS;
      var vy0 = trustMine ? mine[c0 * 4 + 1] - mine[c0 * 4 + 3] : (b0.y - b0.oy) / SNAP_TICKS;
      a0.x = b0.x; a0.y = b0.y; a0.g = b0.g;
      a0.ox = b0.x - vx0; a0.oy = b0.y - vy0;
    }
    predMe.dead = src.dead; predMe.respawn = src.respawn; predMe.flail = src.flail;
    if (src.dead) { predOff = null; return; }      // a corpse is drawn as the server has it

    if (!src.dead) {
      // Replay our own inputs from the tick the snapshot describes up to now. The
      // length comes from the tick numbers, not from a latency estimate, so there
      // is nothing left to wobble.
      var ticks = Math.min(gap, 90);
      for (var r0 = ticks; r0 &gt;= 1; r0--) {
        var t1 = (predTick - r0 + 1) &amp; 0xffff;
        var hist = inputHist[t1 &amp; 255];
        if (hist) predMe.input = hist;
        S.step(predWorld);
        recordState(t1);
      }
    }

    // Whatever is left after replaying is genuine correction: slide it in — but as
    // ONE offset for the whole body, not one per point.
    //
    // Per-point offsets were the stretching. A correction is rarely the same size
    // at every joint, and each offset eased out at its own capped rate, so for the
    // twenty-odd frames a big correction takes to disappear the body was drawn
    // with its joints displaced by different amounts: a stickman pulled long.
    // Splitting the correction into a translation and a shape fixes it. The
    // translation is what a player would notice being wrong, so it eases; the
    // shape is small and local, so it is simply taken.
    var sumX = 0, sumY = 0;
    for (var o0 = 0; o0 &lt; predMe.pts.length; o0++) {
      sumX += wasX[o0] - predMe.pts[o0].x;
      sumY += wasY[o0] - predMe.pts[o0].y;
    }
    predOff = { x: sumX / predMe.pts.length, y: sumY / predMe.pts.length };
    var far = Math.abs(predOff.x) + Math.abs(predOff.y);
    // A respawn moves you across the map, and smoothing it would draw the body
    // gliding through the scenery. Detect it properly — coming back from dead —
    // rather than by distance, because a hard knockback at high latency covers
    // just as much ground and does want smoothing.
    if ((wasDead &amp;&amp; !src.dead) || far &gt; 900) predOff = null;
  }

  // The visual offset decays every frame, so a correction is a short glide rather
  // than a step. It only moves the drawing; the simulation stays authoritative.
  function predOffset(i) {
    return predOff;                 // one rigid offset, so the body cannot stretch
  }
  function predOffsetDecay() {
    if (!predOff) return;
    // Percentage decay alone reveals a big correction too fast: 18% of the
    // hundred-odd pixels a sword adds is still a visible lurch. Cap the reveal
    // per frame so even a violent correction arrives as motion, not a step.
    for (var ax = 0; ax &lt; 2; ax++) {
      var key = ax ? &apos;y&apos; : &apos;x&apos;;
      var v = predOff[key];
      if (!v) continue;
      var step = Math.min(Math.max(Math.abs(v) * 0.13, 0.5), 4.5);
      predOff[key] = Math.abs(v) &lt;= step ? 0 : v - (v &gt; 0 ? step : -step);
    }
    if (Math.abs(predOff.x) &lt; 0.3 &amp;&amp; Math.abs(predOff.y) &lt; 0.3) predOff = null;
  }

  // ---- multiplayer ---------------------------------------------------------
  // The room is authoritative: we send 4-byte inputs and draw the snapshots it
  // sends back. No local prediction, so a swing lands when the server says it
  // did — at 20Hz with interpolation that reads as a slight weight, not as lag.
  var Wire = window.StickWire;
  var ws = null, roster = [], myId = null, snapAt = 0, snapGap = 34, myPing = 0, mapSeed = null;
  var createBtn = document.getElementById(&apos;sf-create&apos;);
  var joinBtn = document.getElementById(&apos;sf-join&apos;);

  function api(path, opts) {
    return fetch(NET_URL + path, opts).then(function (r) {
      if (!r.ok) throw new Error(&apos;http &apos; + r.status);
      return r.json();
    });
  }

  // Latency is measured against the room&apos;s own Durable Object, not the edge, so
  // it reflects the distance to where the fight actually runs. This relies on the
  // connection to the origin already being warm — refreshRooms() fetches the room
  // list first, and these pings then share that HTTP/2 connection. Measured cold,
  // the first number would include a TLS handshake (~450ms from Jakarta) instead
  // of the ~25ms round trip that actually matters.
  function pingRoom(code) {
    var t0 = (window.performance || Date).now();
    return fetch(NET_URL + &apos;/room/&apos; + code + &apos;/ping&apos;, { cache: &apos;no-store&apos; })
      .then(function () { return Math.round((window.performance || Date).now() - t0); })
      .catch(function () { return 9999; });
  }

  // Twelve rooms used to be pinged in one Promise.all, which meant twelve requests
  // leaving at once and then queueing — on a phone&apos;s uplink each one waits behind
  // the others, and every room comes back looking further away than it is. Two at
  // a time keeps the list quick without the measurements inflating each other.
  function pingAll(rooms) {
    var out = new Array(rooms.length), next = 0;
    function worker() {
      if (next &gt;= rooms.length) return Promise.resolve();
      var i = next++, r = rooms[i];
      return pingRoom(r.code).then(function (ms) {
        r.ping = ms; out[i] = r;
        return worker();
      });
    }
    var lanes = [];
    for (var k = 0; k &lt; Math.min(2, rooms.length); k++) lanes.push(worker());
    return Promise.all(lanes).then(function () { return out; });
  }

  // Fake latency, for testing the netcode without a VPN or a friend abroad.
  // ?lag=150 on the URL delays every packet in both directions by 150ms, so a
  // second tab can be given a 300ms round trip and played side by side with a
  // clean one — with the lag-compensation switch to flip between them.
  var FAKE_LAG = 0;
  try {
    var m = /[?&amp;]lag=(\d+)/.exec(window.location &amp;&amp; window.location.search || &apos;&apos;);
    if (m) FAKE_LAG = Math.max(0, Math.min(1000, parseInt(m[1], 10)));
  } catch (e) {}

  function lagWrap(sock) {
    if (!FAKE_LAG) return sock;
    var real = sock.send.bind(sock);
    sock.send = function (d) { setTimeout(function () { try { real(d); } catch (e) {} }, FAKE_LAG); };
    return sock;
  }
  function lagDeliver(fn) {
    if (!FAKE_LAG) return fn;
    return function (ev) { setTimeout(function () { fn(ev); }, FAKE_LAG); };
  }

  // ---- the room panel ------------------------------------------------------
  // Shown between rounds, over the frozen arena. During a fight it is not there
  // at all: dropping in is the point of the game, so nothing gates it.

  function sendJson(o) {
    if (ws &amp;&amp; ws.readyState === 1) { try { ws.send(JSON.stringify(o)); } catch (e) {} }
  }

  function renderRoom() {
    if (mode !== &apos;online&apos; || phase !== &apos;over&apos;) {
      roomPanel.hidden = true;
      stopCountdown();
      return;
    }
    roomPanel.hidden = false;
    var table = finalTable &amp;&amp; finalTable.length ? finalTable : null;
    var winnerRow = table &amp;&amp; table[0];
    roomTitle.textContent = winnerRow &amp;&amp; winnerRow.won
      ? winnerRow.name + &apos; wins&apos;
      : (winnerRow ? winnerRow.name + &apos; leads&apos; : &apos;Round over&apos;);

    finalEl.innerHTML = &apos;&apos;;
    // The live roster carries ready flags and who is away; the final table
    // carries the scores. They are keyed by name because the table is a frozen
    // snapshot and a seat may have been re-slotted since.
    var readyBy = {}, awayBy = {};
    for (var i = 0; i &lt; roster.length; i++) {
      if (!roster[i]) continue;
      readyBy[roster[i].name] = !!roster[i].ready;
      awayBy[roster[i].name] = !!roster[i].away;
    }
    (table || []).forEach(function (r, idx) {
      var row = document.createElement(&apos;div&apos;);
      row.className = &apos;r&apos; + (r.won ? &apos; win&apos; : &apos;&apos;) + (awayBy[r.name] ? &apos; away&apos; : &apos;&apos;);
      var dot = document.createElement(&apos;span&apos;);
      dot.className = &apos;dot&apos;;
      dot.style.background = TEAM[idx % TEAM.length];
      var nm = document.createElement(&apos;span&apos;);
      nm.className = &apos;nm&apos;;
      nm.textContent = r.name;
      var k = document.createElement(&apos;span&apos;);
      k.className = &apos;k&apos;;
      k.textContent = r.kills + &apos; / &apos; + r.deaths;
      var st = document.createElement(&apos;span&apos;);
      st.className = &apos;st&apos;;
      st.textContent = awayBy[r.name] ? &apos;away&apos; : (readyBy[r.name] ? &apos;✅ ready&apos; : &apos;…&apos;);
      row.appendChild(dot); row.appendChild(nm); row.appendChild(k); row.appendChild(st);
      finalEl.appendChild(row);
    });

    readyBtn.textContent = iAmReady ? &apos;Cancel&apos; : &apos;Ready&apos;;
    var live = 0;
    for (var j = 0; j &lt; roster.length; j++) if (roster[j] &amp;&amp; !roster[j].away) live++;
    roomSub.textContent = live &lt; 2
      ? &apos;A rematch needs two people in the room. Share the link.&apos;
      : &apos;kills / deaths — next round is a fresh arena and a clean slate.&apos;;
  }

  var countdownTimer = null;
  function stopCountdown() {
    if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
    countdownEl.textContent = &apos;&apos;;
  }
  function runCountdown(endsAt) {
    stopCountdown();
    if (!endsAt) return;
    var tick = function () {
      var left = Math.max(0, endsAt - Date.now());
      countdownEl.textContent = &apos;Next round in &apos; + Math.ceil(left / 1000) + &apos;…&apos;;
      if (left &lt;= 0) stopCountdown();
    };
    tick();
    countdownTimer = setInterval(tick, 200);
  }

  readyBtn.addEventListener(&apos;click&apos;, function () {
    iAmReady = !iAmReady;
    readyBtn.textContent = iAmReady ? &apos;Cancel&apos; : &apos;Ready&apos;;
    sendJson({ type: &apos;ready&apos;, ready: iAmReady });
  });

  // Arriving through an invite link skips the menu, so the name box there was
  // never seen: the same edit has to be possible from inside the room.
  roomNameEl.addEventListener(&apos;change&apos;, function () {
    var n = (roomNameEl.value || &apos;you&apos;).slice(0, 12).trim() || &apos;you&apos;;
    roomNameEl.value = n;
    nameEl.value = n;
    try { localStorage.setItem(&apos;sf_name&apos;, n); } catch (e) {}
    sendJson({ type: &apos;setName&apos;, name: n });
  });

  var INVITE_LABEL = &apos;🔗 invite&apos;;

  function inviteLink(code, key) {
    var base = window.location.origin + window.location.pathname;
    return base + &apos;?room=&apos; + encodeURIComponent(code) + (key ? &apos;&amp;key=&apos; + encodeURIComponent(key) : &apos;&apos;);
  }

  inviteBtn.addEventListener(&apos;click&apos;, function () {
    var url = inviteUrlEl.value;
    function reveal() {
      // Clipboard access is refused in plenty of mobile contexts, so always
      // leave the link on screen to copy by hand.
      inviteUrlEl.hidden = false;
      inviteUrlEl.select &amp;&amp; inviteUrlEl.select();
    }
    if (navigator.clipboard &amp;&amp; navigator.clipboard.writeText) {
      navigator.clipboard.writeText(url).then(function () {
        inviteBtn.textContent = &apos;✓ copied&apos;;
        setTimeout(function () { inviteBtn.textContent = INVITE_LABEL; }, 1600);
      }).catch(reveal);
    } else reveal();
  });

  function connect(code, password, ping, key) {
    var name = (nameEl.value || &apos;you&apos;).slice(0, 12);
    try { localStorage.setItem(&apos;sf_name&apos;, name); } catch (e) {}
    roomNameEl.value = name;
    note.textContent = &apos;Connecting to &apos; + code + &apos;…&apos;;
    roomCode = code;
    lastPassword = password || &apos;&apos;;
    leaving = false;
    if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
    if (key !== undefined) roomKey = key || &apos;&apos;;
    var url = NET_URL.replace(/^http/, &apos;ws&apos;) + &apos;/room/&apos; + code + &apos;/ws?name=&apos; +
              encodeURIComponent(name) + &apos;&amp;pw=&apos; + encodeURIComponent(password || &apos;&apos;) +
              (roomKey ? &apos;&amp;key=&apos; + encodeURIComponent(roomKey) : &apos;&apos;) +
              // Offered on every connect. The server only honours it if it still
              // holds the matching seat, so a stale one costs nothing.
              (seatToken ? &apos;&amp;token=&apos; + encodeURIComponent(seatToken) : &apos;&apos;);
    ws = lagWrap(new WebSocket(url));
    ws.binaryType = &apos;arraybuffer&apos;;
    // Only a seed, so the strip has something to show before the first snapshot.
    // measureRtt overwrites it a fraction of a second later with the real thing.
    myPing = (ping || 0) + FAKE_LAG * 2;

    ws.onopen = function () {
      world = S.createWorld(1);      // replaced as soon as the roster names the seed
      world.players = {};
      bots = []; winner = null; myId = null;
      predWorld = null; predMe = null;
      mode = &apos;online&apos;;
      retries = 0;                   // we are back; the next drop gets a full budget
      note.textContent = &apos;&apos;;
      showGame();
      last = 0; acc = 0;
    };
    ws.onmessage = lagDeliver(function (ev) {
      if (typeof ev.data === &apos;string&apos;) {
        var msg = JSON.parse(ev.data);
        if (msg.type === &apos;hello&apos;) {
          // Our seat, and the token that gets it back — score included — if this
          // socket dies. Kept in memory only: a token is per-session, and one
          // restored from storage in a new tab would fight the live tab over the
          // same seat.
          seatToken = msg.token || &apos;&apos;;
          return;
        }
        if (msg.type === &apos;roster&apos;) {
          // Every room generates its own arena from a seed. Without rebuilding on
          // it we would draw different platforms than the ones being fought on.
          // A new round is exactly this: a new seed arrives and the arena changes
          // under everyone at the same moment.
          if (msg.seed != null &amp;&amp; msg.seed !== mapSeed) {
            mapSeed = msg.seed;
            var keep = world ? world.players : {};
            world = S.createWorld(mapSeed);
            world.players = keep;
          }
          roster = [];
          for (var i = 0; i &lt; msg.slots.length; i++) roster[msg.slots[i].slot] = msg.slots[i];
          if (roster[msg.you]) myId = roster[msg.you].id;
          KILLS_TO_WIN = msg.killsToWin || KILLS_TO_WIN;

          var wasPhase = phase;
          phase = msg.phase || &apos;fighting&apos;;
          finalTable = msg.final || null;
          if (phase === &apos;over&apos;) {
            // The server decides the round is over, so the client&apos;s own guess at
            // a winner is no longer needed and would only disagree with it.
            winner = null;
            if (wasPhase !== &apos;over&apos;) iAmReady = false;
            runCountdown(msg.countdownEndsAt || 0);
          } else if (wasPhase === &apos;over&apos;) {
            // A fresh round: clean slate locally too, or the predicted body keeps
            // replaying inputs recorded against the arena that just went away.
            iAmReady = false;
            winner = null;
            predWorld = null; predMe = null; predOff = null;
            stopCountdown();
          }
          if (msg.room &amp;&amp; msg.room.code) {
            roomInvite = msg.room.invite || &apos;&apos;;
            inviteBtn.hidden = false;
            inviteUrlEl.value = inviteLink(msg.room.code, roomInvite);
          }
          renderRoom();
        }
        return;
      }
      Wire.decodeSnapshot(ev.data, world, roster, S);
      // Resume interpolating from the drawn position, not from the previous
      // snapshot: otherwise a snapshot arriving mid-interpolation makes every
      // remote body hop forward by whatever was left of the last one.
      for (var rid in world.players) {
        // Never the predicted body. It is not interpolated — it is drawn from the
        // prediction — and its ox/oy is the only velocity reference reconciliation
        // has. Rewriting that to a drawn position corrupts it.
        //
        // This is why prediction behaved when switched on from the start and
        // misbehaved when switched off and on again: with it on, this body is never
        // drawn and has no draw coordinates, so the loop skipped it. Switching off
        // draws it once, and from then on the stale coordinates left behind were
        // copied over its velocity on every single snapshot.
        if (predMe &amp;&amp; rid === myId) continue;
        var rp = world.players[rid];
        for (var ri = 0; ri &lt; rp.pts.length; ri++) {
          var rq = rp.pts[ri];
          if (rq.dx !== undefined) { rq.ox = rq.dx; rq.oy = rq.dy; }
        }
      }
      var nowMs = (window.performance || Date).now();
      // measure the real gap instead of assuming one: the server&apos;s rate can
      // change, and interpolating over a longer window than the actual interval
      // means always drawing the past
      if (snapAt) snapGap = Math.max(20, Math.min(120, snapGap * 0.7 + (nowMs - snapAt) * 0.3));
      snapAt = nowMs;
      me = myId ? world.players[myId] : null;
      measureRtt(me);
      if (me &amp;&amp; !predMe) predStart();
      else if (me) predSync(me, false);
      // Online, the room decides when a round is over and says so. Guessing from
      // the snapshot as well would put a &quot;wins&quot; banner up a moment before or
      // after the server agreed, and on a bad frame disagree about who.
    });
    ws.onclose = function () {
      if (mode !== &apos;online&apos;) return;
      ws = null;
      // The seat is held for a grace period, so a dropped connection is worth
      // trying to walk back into rather than dumping someone at the menu with
      // their nine kills gone. Only with a token: without one there is nothing
      // to come back to and the room would just hand out a fresh seat.
      if (seatToken &amp;&amp; !leaving &amp;&amp; retries &lt; 4) {
        retries++;
        note.textContent = &apos;Reconnecting…&apos;;
        retryTimer = setTimeout(function () {
          connect(roomCode, lastPassword, myPing);
        }, 600 * retries);
        return;
      }
      mode = &apos;menu&apos;; showMenu();
      note.textContent = leaving ? &apos;&apos; : &apos;Disconnected.&apos;;
    };
    ws.onerror = function () { note.textContent = &apos;Could not connect.&apos;; };
  }

  function refreshRooms() {
    roomsEl.hidden = false;
    roomsEl.innerHTML = &apos;&lt;div&gt;looking for rooms…&lt;/div&gt;&apos;;
    api(&apos;/lobby/list&apos;).then(function (data) {
      var rooms = (data.rooms || []).slice(0, 12);
      if (!rooms.length) { roomsEl.innerHTML = &apos;&lt;div&gt;no rooms yet — create one?&lt;/div&gt;&apos;; return; }
      return pingAll(rooms).then(function (list) {
        list.sort(function (a, b) { return a.ping - b.ping; });   // nearest first
        roomsEl.innerHTML = &apos;&apos;;
        list.forEach(function (r) {
          var row = document.createElement(&apos;div&apos;);
          row.innerHTML = &apos;&lt;span&gt;&apos; + (r.private ? &apos;🔒 &apos; : &apos;&apos;) +
            r.code + &apos; · &apos; + escapeHtml(r.name) + &apos;&lt;/span&gt;&apos; +
            &apos;&lt;span&gt;&apos; + flagOf(r.country) + &apos; &apos; + escapeHtml(r.colo || &apos;??&apos;) +
            &apos; · &apos; + r.players + &apos;/&apos; + r.max + &apos; · &apos; +
            (r.ping &gt; 3000 ? &apos;—&apos; : r.ping + &apos;ms&apos;) + &apos;&lt;/span&gt;&apos;;
          row.addEventListener(&apos;click&apos;, function () {
            if (r.players &gt;= r.max) { note.textContent = &apos;That room is full.&apos;; return; }
            var pw = r.private ? (window.prompt(&apos;Password for room &apos; + r.code) || &apos;&apos;) : &apos;&apos;;
            if (r.private &amp;&amp; !pw) return;
            connect(r.code, pw, r.ping);
          });
          roomsEl.appendChild(row);
        });
      });
    }).catch(function () {
      roomsEl.innerHTML = &apos;&lt;div&gt;the server is not answering.&lt;/div&gt;&apos;;
    });
  }

  // An ISO country code turns into its flag with two regional-indicator letters.
  function flagOf(cc) {
    if (!cc || cc.length !== 2) return &apos;🏳&apos;;
    return String.fromCodePoint(0x1F1E6 + cc.charCodeAt(0) - 65,
                                0x1F1E6 + cc.charCodeAt(1) - 65);
  }

  function escapeHtml(s) {
    return String(s).replace(/[&amp;&lt;&gt;&quot;]/g, function (c) {
      return { &apos;&amp;&apos;: &apos;&amp;amp;&apos;, &apos;&lt;&apos;: &apos;&amp;lt;&apos;, &apos;&gt;&apos;: &apos;&amp;gt;&apos;, &apos;&quot;&apos;: &apos;&amp;quot;&apos; }[c];
    });
  }

  if (!NET_URL) {
    createBtn.disabled = joinBtn.disabled = true;
    note.textContent = &apos;Online rooms are off — no server is configured.&apos;;
  } else {
    createBtn.addEventListener(&apos;click&apos;, function () {
      var rn = window.prompt(&apos;Room name:&apos;, (nameEl.value || &apos;our&apos;) + &quot;&apos;s room&quot;);
      if (rn === null) return;
      var pw = window.prompt(&apos;Password (leave blank for a public room):&apos;, &apos;&apos;) || &apos;&apos;;
      note.textContent = &apos;Creating room…&apos;;
      api(&apos;/lobby/create&apos;, {
        method: &apos;POST&apos;,
        headers: { &apos;Content-Type&apos;: &apos;application/json&apos; },
        body: JSON.stringify({ name: rn, password: pw })
      }).then(function (res) {
        if (res.error) { note.textContent = res.error; return; }
        connect(res.code, pw, 0);
      }).catch(function () { note.textContent = &apos;Could not create the room.&apos;; });
    });
    joinBtn.addEventListener(&apos;click&apos;, refreshRooms);
  }

  // Send input only when it actually changes.
  //
  // Cloudflare bills an incoming WebSocket message as a request (at 20:1), and
  // outgoing ones as nothing — so the upstream input stream, not the 20Hz
  // snapshot broadcast, is what burns the free tier. Blindly sending 20 packets
  // a second costs ~1 billed request per player per second even while standing
  // still. The server treats input as sticky state, so a repeat is redundant
  // anyway; a keepalive every 250ms keeps us well inside its 20s idle timeout.
  // Buttons go up the instant they change, because a late jump is a death. Aim
  // is different: it slides continuously, so left alone it would change on every
  // single sample and cost as much as sending blindly. It is quantised to ~3.6
  // degrees and rate-limited to 10Hz, which no one can feel, plus a 4Hz keepalive
  // so the server never thinks we went quiet.
  var lastBits = -1, lastAim = null, lastSentAt = 0, pendingFire = false;
  var lastSent = { l: 0, r: 0, jump: 0, duck: 0, fire: 0, discard: 0, special: 0, aim: 0 };

  // Called once per predicted tick, and returns the input the server will be
  // acting on for that tick — which is the whole point.
  //
  // Predicting with the input actually held, rather than the input actually sent,
  // was the bug behind both complaints. Sending was on its own 50ms timer, so the
  // server&apos;s timeline was a coarser version of ours and the two simulations were
  // never running the same thing: the shape of the body disagreed by 27px while
  // simply walking undisturbed, on every snapshot, forever. That is a correction
  // that can never succeed — visible as endless rubber banding, and as a stretched
  // stickman once each joint eased its own share of it out.
  //
  // So the timeline is defined here: a change is sent on the tick it happens, and
  // on any tick where nothing is sent, the server is still re-using the last
  // packet — which is exactly what gets recorded and predicted with.
  function sendInput(t) {
    if (mode !== &apos;online&apos; || !ws || ws.readyState !== 1 || !me) return lastSent;
    var i = me.input;
    var fire = (i.fire || pendingFire) ? 1 : 0;
    var bits = (i.l ? 1 : 0) | (i.r ? 2 : 0) | (i.jump ? 4 : 0) | (i.duck ? 8 : 0) |
               (fire ? 16 : 0) | (i.discard ? 32 : 0) | (i.special ? 64 : 0);
    var aimQ = Math.round(i.aim * 16) / 16;
    var now = Date.now();
    var since = now - lastSentAt;
    // Buttons go up the instant they change, because a late jump is a death. Aim
    // slides continuously, so it is quantised to ~3.6 degrees and rate-limited;
    // the keepalive keeps the server&apos;s idea of us fresh.
    var aimDue = aimQ !== lastAim &amp;&amp; since &gt;= 100;
    if (bits === lastBits &amp;&amp; !aimDue &amp;&amp; since &lt; 250) return lastSent;
    lastBits = bits; lastAim = aimQ; lastSentAt = now; pendingFire = false;
    lastSent = { l: i.l ? 1 : 0, r: i.r ? 1 : 0, jump: i.jump ? 1 : 0, duck: i.duck ? 1 : 0,
                 fire: fire, discard: i.discard ? 1 : 0, special: i.special ? 1 : 0, aim: aimQ };
    sentAt[t &amp; 255] = now;
    sfStats.sentSeq = t;
    ws.send(Wire.encodeInput(lastSent, t));
    return lastSent;
  }

  window.addEventListener(&apos;resize&apos;, sizeCanvas);
  sizeCanvas();
  requestAnimationFrame(frame);

  // Someone handed us a link: join that room directly, password skipped by the
  // key it carries. The name box on the menu is never seen on this path, which
  // is exactly why the room panel has one of its own.
  (function joinFromLink() {
    if (!NET_URL) return;
    var q = String(window.location.search || &apos;&apos;);
    var code = (/[?&amp;]room=([A-Z0-9]{4,8})/i.exec(q) || [])[1];
    if (!code) return;
    var key = (/[?&amp;]key=([^&amp;]+)/.exec(q) || [])[1] || &apos;&apos;;
    note.textContent = &apos;Joining &apos; + code.toUpperCase() + &apos;…&apos;;
    connect(code.toUpperCase(), &apos;&apos;, 0, decodeURIComponent(key));
  })();
})();
&lt;/script&gt;

</content>
 </entry>
 
 <entry>
   <title>Little Kitten</title>
   <link href="https://ashura.id/little-kitten"/>
   <updated>2026-08-05T00:00:00+00:00</updated>
   <id>https://ashura.id/little-kitten</id>
   <content type="html">&lt;style&gt;
#kitten-banner{position:sticky;top:0;z-index:50;background:#fbfbf7;border-bottom:2px solid #222;margin:0 0 8px;box-shadow:0 4px 10px rgba(0,0,0,.06);}
#kitten-banner canvas{display:block;width:100%;height:auto;cursor:pointer;touch-action:pan-y;user-select:none;-webkit-user-select:none;}
#kitten-banner .bar{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;color:#555;padding:4px 10px 6px;font-family:inherit;}
#kitten-banner .bar b{color:#111;}
#kitten-progress{position:relative;height:4px;background:#e6e6df;}
#kitten-progress &gt; i{display:block;height:100%;width:0;background:#222;transition:width .2s;}

/* invisible-ink post body: an unearned word takes up no space at all, so the
   page grows as you play instead of sitting under a tall blank slab. The real
   words stay in the markup so crawlers and readers without JS still get the
   post; display:none is enough to keep them out of a select-all copy. */
#ink .w{display:none;}
#ink .w.on{display:inline;animation:inkfade .45s ease both;}
@keyframes inkfade{from{opacity:0}to{opacity:1}}
#ink .blk-off{display:none;} /* paragraphs, headings and rules with nothing in them yet */
#ink{min-height:1rem;}

/* Escape hatch for anyone who cannot play: off-screen until focused, so Tab
   reaches it and a screen reader announces it, without tempting a mouse. */
.ink-skip{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;}
.ink-skip:focus{position:static;width:auto;height:auto;display:inline-block;margin:6px 0 2px;padding:6px 10px;font-size:13px;border:1.5px dashed #bbb;border-radius:8px;background:#fbfbf7;color:#222;}
.ink-tools{margin:4px 0 0;text-align:right;}
.ink-reset{font:inherit;font-size:11px;color:#aaa;background:none;border:0;padding:2px 4px;cursor:pointer;text-decoration:underline dotted;}
.ink-reset:hover,.ink-reset:focus{color:#555;}
&lt;/style&gt;

&lt;div id=&quot;kitten-banner&quot;&gt;
  &lt;canvas id=&quot;kitten-canvas&quot; width=&quot;720&quot; height=&quot;340&quot;&gt;&lt;/canvas&gt;
  &lt;div id=&quot;kitten-progress&quot;&gt;&lt;i&gt;&lt;/i&gt;&lt;/div&gt;
  &lt;div class=&quot;bar&quot;&gt;
    &lt;span&gt;🐱 &lt;b id=&quot;kt-words&quot;&gt;0&lt;/b&gt; / &lt;b id=&quot;kt-total&quot;&gt;0&lt;/b&gt; kata terbuka&lt;/span&gt;
    &lt;span id=&quot;kt-msg&quot;&gt;Tap makanannya untuk mulai&lt;/span&gt;
    &lt;span&gt;Skor: &lt;b id=&quot;kt-score&quot;&gt;0&lt;/b&gt;&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;ink-tools&quot;&gt;
  &lt;a href=&quot;#ink&quot; class=&quot;ink-skip&quot; id=&quot;ink-skip&quot;&gt;Lewati permainan — tampilkan semua tulisan&lt;/a&gt;
  &lt;button type=&quot;button&quot; class=&quot;ink-reset&quot; id=&quot;ink-reset&quot;&gt;⟲ reset progres&lt;/button&gt;
&lt;/p&gt;

&lt;div id=&quot;ink&quot;&gt;

  &lt;p&gt;&lt;em&gt;Saya suka kucing, tapi belum pernah berhasil memelihara satu pun.&lt;/em&gt;&lt;/p&gt;

  &lt;hr /&gt;

  &lt;h3 id=&quot;susu-sapi&quot;&gt;Susu sapi&lt;/h3&gt;

  &lt;p&gt;Waktu SD, saya pernah punya anak kucing. Saya kasih dia susu sapi, susu kotak yang biasa saya minum sendiri. Buat anak sembilan tahun, logikanya masuk akal: kucing kan suka susu.&lt;/p&gt;

  &lt;p&gt;Belasan tahun kemudian saya baru tahu kalau kebanyakan kucing tidak bisa mencerna laktosa.&lt;/p&gt;

  &lt;p&gt;Kucingnya diare. Lalu keluar kalimat ini:&lt;/p&gt;

  &lt;blockquote&gt;
    &lt;p&gt;&lt;em&gt;“Kayaknya kamu belum bisa pelihara kucing.”&lt;/em&gt;&lt;/p&gt;
  &lt;/blockquote&gt;

  &lt;p&gt;Keluarga saya tidak salah. Kucingnya akhirnya diberikan ke orang lain. Dari situ saya belajar kalau niat baik saja tidak cukup kalau tidak tahu caranya.&lt;/p&gt;

  &lt;p&gt;Susu kotak di game di atas itu bukan karangan. Itu beneran pernah terjadi. Bedanya, dulu tidak ada tombol untuk mengulang.&lt;/p&gt;

  &lt;h3 id=&quot;satu-ibu-empat-anak&quot;&gt;Satu ibu, empat anak&lt;/h3&gt;

  &lt;p&gt;Lompat ke zaman kuliah, semester akhir. Masa-masa duduk di depan laptop delapan jam, tidak menulis satu paragraf pun, tapi tetap capek.&lt;/p&gt;

  &lt;p&gt;Di depan rumah ada kucing liar, satu induk dan empat anak. Setiap hari mereka datang, minta dielus, lalu tidur di atas sandal. Saya lebih sering menemani mereka daripada membuka file skripsi. &lt;em&gt;No regrets, though.&lt;/em&gt;&lt;/p&gt;

  &lt;p&gt;Lalu suatu hari mereka naik ke plafon. Tengah malam, empat anak kucing lari-larian di atas kepala orang serumah. Tetangga sepertinya tidak terlalu menikmati.&lt;/p&gt;

  &lt;p&gt;Mereka bukan kucing saya. Selama beberapa bulan itu, saya cuma orang yang mereka datangi. Ternyata itu sudah cukup.&lt;/p&gt;

  &lt;h3 id=&quot;yang-tidak-dicatat-siapa-pun&quot;&gt;Yang tidak dicatat siapa pun&lt;/h3&gt;

  &lt;p&gt;Kucing tidak punya rencana lima tahun. Tidak menabung, tidak peduli IPK. Makan, tidur, lompat dari tempat tinggi, besoknya diulang lagi.&lt;/p&gt;

  &lt;p&gt;Susu sapi itu tidak bisa saya tarik kembali. Kucing-kucing di plafon juga pasti sudah lupa sama saya, dan tidak apa-apa.&lt;/p&gt;

  &lt;p&gt;Yang bisa saya lakukan sekarang cuma memberi makan kucing yang lapar hari ini. Buat seekor anak kucing, dielus sepuluh menit itu sudah banyak.&lt;/p&gt;

  &lt;p&gt;Makasih sudah menyuapinya sampai kenyang. Kalau hari ini ketemu kucing liar, jangan kasih susu sapi, ya. Kasih air saja. Asif, &lt;em&gt;signing off.&lt;/em&gt; 🐾&lt;/p&gt;

&lt;/div&gt;

&lt;script&gt;
(function(){
  // ---- reveal setup: wrap every word in the post body into a span ----
  var ink = document.getElementById(&apos;ink&apos;);
  var words = [];

  (function walk(node){
    // snapshot children first: we mutate the tree as we go, and appending a
    // DocumentFragment empties it, so a live childNodes loop would re-process
    // the spans we just created (infinite loop).
    var kids = Array.prototype.slice.call(node.childNodes);
    for (var k=0;k&lt;kids.length;k++){
      var n = kids[k];
      if (n.nodeType===3){ // text node
        if (!/\S/.test(n.nodeValue)) continue; // whitespace-only, leave as is
        var parts = n.nodeValue.split(/(\s+)/);
        var frag = document.createDocumentFragment();
        for (var p=0;p&lt;parts.length;p++){
          if (/\S/.test(parts[p])){
            var s = document.createElement(&apos;span&apos;);
            s.className=&apos;w&apos;; s.textContent = parts[p];
            s._wi = words.length; // its index, for the block bookkeeping below
            frag.appendChild(s); words.push(s);
          } else if (parts[p]){
            frag.appendChild(document.createTextNode(parts[p]));
          }
        }
        node.replaceChild(frag, n);
      } else if (n.nodeType===1){ // element (e.g. &lt;em&gt;) — recurse, never a created span
        walk(n);
      }
    }
  })(ink);

  // Each top-level block remembers the index of its first word, so it can stay
  // collapsed until that word is earned. Words are revealed as a prefix, so
  // &quot;first word &lt; revealed count&quot; is exactly the right test. A block with no
  // words of its own (a horizontal rule) borrows the threshold of the next block
  // that has some, and so appears with the section it introduces.
  var blocks = [];
  (function(){
    var kids = [];
    for (var c=0;c&lt;ink.childNodes.length;c++) if (ink.childNodes[c].nodeType===1) kids.push(ink.childNodes[c]);
    for (var b=0;b&lt;kids.length;b++){
      var first = -1;
      (function scan(node){
        for (var i=0;i&lt;node.childNodes.length;i++){
          var n = node.childNodes[i];
          if (n.nodeType!==1) continue;
          if (typeof n._wi === &apos;number&apos;){ if (first&lt;0 || n._wi&lt;first) first = n._wi; }
          else scan(n);
        }
      })(kids[b]);
      blocks.push({ el:kids[b], at:first, on:null });
    }
    var next = Infinity;
    for (var k=blocks.length-1;k&gt;=0;k--){
      if (blocks[k].at &lt; 0) blocks[k].at = next; else next = blocks[k].at;
    }
  })();
  function syncBlocks(n){
    for (var b=0;b&lt;blocks.length;b++){
      var on = blocks[b].at &lt; n;
      if (blocks[b].on === on) continue;
      blocks[b].on = on;
      if (on) blocks[b].el.classList.remove(&apos;blk-off&apos;);
      else blocks[b].el.classList.add(&apos;blk-off&apos;);
    }
  }

  var TOTAL = words.length;
  var FOOD_SCORE = 55;        // score per bite
  var TARGET_BITES = 17;      // a clean run ≈ 17 bites, however long the post gets
  var PER_WORD = Math.max(1.5, FOOD_SCORE * TARGET_BITES / TOTAL);
  var GROW_STEPS = 10;        // kitten reaches full size after 10 bites
  var FINISH = TOTAL * PER_WORD;
  var STARVE_COST = FINISH * 0.30;  // starving chops 30% of the whole post
  var POISON_COST = FINISH * 0.45;  // milk costs more — it was your choice
  document.getElementById(&apos;kt-total&apos;).textContent = TOTAL;

  var BEST_KEY = &apos;ashura_kitten_highscore&apos;;
  var best = 0;
  try { best = parseInt(localStorage.getItem(BEST_KEY) || &apos;0&apos;, 10) || 0; } catch(e){}

  // ---- how much of the post this reader has already earned ----
  // Stored as a word count, not a score, so it survives edits to the prose.
  var SAVE_KEY = &apos;ashura_ink_&apos; + ((window.location &amp;&amp; window.location.pathname) || &apos;kitten&apos;);
  var savedWords = -1, pendingWords = -1, lastSave = 0;
  function flushSave(){
    if (pendingWords &lt; 0 || pendingWords === savedWords) return;
    savedWords = pendingWords; lastSave = Date.now();
    try { localStorage.setItem(SAVE_KEY, String(savedWords)); } catch(e){}
  }
  function persist(n, force){
    pendingWords = n; // words land several per second; write at most every 250ms
    if (force || Date.now() - lastSave &gt;= 250) flushSave();
  }
  // whatever the throttle is still holding must land before the reader leaves
  window.addEventListener(&apos;pagehide&apos;, flushSave);
  window.addEventListener(&apos;blur&apos;, flushSave);
  document.addEventListener(&apos;visibilitychange&apos;, flushSave);
  function loadProgress(){
    var n = 0;
    try { n = parseInt(localStorage.getItem(SAVE_KEY) || &apos;0&apos;, 10) || 0; } catch(e){}
    return Math.max(0, Math.min(TOTAL, n));
  }

  var unlocked = false; // set by the skip link or a win: stop re-hiding words

  var wordsEl = document.getElementById(&apos;kt-words&apos;);
  var progEl = document.querySelector(&apos;#kitten-progress &gt; i&apos;);
  function show(i, on){
    if (!!words[i]._on === !!on) return; // already in the right state
    words[i]._on = on;
    if (on) words[i].classList.add(&apos;on&apos;); else words[i].classList.remove(&apos;on&apos;);
  }
  function reveal(score){
    if (unlocked) return TOTAL; // nothing gets taken back once the post is open
    var n = Math.min(TOTAL, Math.floor(score / PER_WORD));
    for (var i=0;i&lt;TOTAL;i++) show(i, i&lt;n);
    syncBlocks(n);
    wordsEl.textContent = n;
    progEl.style.width = Math.min(100,(score/FINISH)*100)+&apos;%&apos;;
    persist(n);
    return n;
  }
  function revealAll(){
    unlocked = true;
    for (var i=0;i&lt;TOTAL;i++) show(i, true);
    syncBlocks(TOTAL);
    wordsEl.textContent = TOTAL; progEl.style.width = &apos;100%&apos;;
    persist(TOTAL, true);
  }

  // ---- the game (white paper, doodle ink) ----
  var cv = document.getElementById(&apos;kitten-canvas&apos;);
  var ctx = cv &amp;&amp; cv.getContext &amp;&amp; cv.getContext(&apos;2d&apos;);
  var W = cv ? cv.width : 0, H = cv ? cv.height : 0;
  var scoreEl = document.getElementById(&apos;kt-score&apos;);
  var msgEl = document.getElementById(&apos;kt-msg&apos;);

  // No canvas (blocked, ancient browser) means no way to earn the words, so give
  // them away rather than leaving an unreadable page behind.
  if (!ctx){
    revealAll();
    msgEl.textContent = &apos;Gamenya tidak bisa jalan di sini — semua tulisan dibuka.&apos;;
    return;
  }

  var R_MIN = 9, R_MAX = 26;
  var state = &apos;ready&apos;; // ready | playing | dead | won | score | scoredead
  var scoreMode = false;
  var cat, items, particles, notes, tufts;
  var t, spawnT, score, checkpoint, flash, shownWords, shownStep, deathMsg;

  function areaTop(){ return H*0.30; }
  function areaBottom(){ return H-14; }

  function sizeCanvas(){
    var cssW = cv.clientWidth || cv.parentElement.clientWidth || 720;
    // roomy yard, but capped so the sticky banner never eats the whole viewport
    var h = Math.max(240, Math.min(380, cssW*0.5));
    cv.width = W = Math.round(cssW);
    cv.height = H = Math.round(h);
    makeTufts();
    if (cat){ cat.x=clamp(cat.x,20,W-20); cat.y=clamp(cat.y,areaTop(),areaBottom()); }
    if (items) for (var i=0;i&lt;items.length;i++){
      items[i].x = clamp(items[i].x, 24, W-24);
      items[i].y = clamp(items[i].y, areaTop()+8, areaBottom()-4);
    }
  }
  function clamp(v,a,b){ return v&lt;a?a:(v&gt;b?b:v); }

  // grass tufts are decoration only — fixed per size so they don&apos;t flicker
  function makeTufts(){
    tufts = [];
    for (var i=0;i&lt;26;i++){
      var s = Math.sin(i*12.9898)*43758.5453; s -= Math.floor(s);
      var s2 = Math.sin(i*78.233)*43758.5453; s2 -= Math.floor(s2);
      tufts.push({x: s*W, y: areaTop()+s2*(areaBottom()-areaTop()), s: 3+s2*3});
    }
  }

  function catRadius(){
    if (scoreMode) return R_MAX;
    var f = Math.min(1, score / (GROW_STEPS*FOOD_SCORE));
    return R_MIN + (R_MAX-R_MIN)*f;
  }
  function growStep(){
    return Math.floor(Math.min(1, score/(GROW_STEPS*FOOD_SCORE))*GROW_STEPS);
  }
  function level(){ // difficulty driver
    if (scoreMode) return 8 + t/420;
    return score / FOOD_SCORE;
  }
  function foodLife(){ return Math.max(120, 230 - level()*7); }        // frames the food waits
  function spawnGap(){ return Math.max(26, 60 - level()*2.2); }        // frames between spawns
  function catSpeed(){ return Math.min(9, 4.8 + level()*0.22) * Math.max(0.6, Math.min(1, W/620)); }
  function pPoison(){ return Math.min(0.34, 0.05 + level()*0.032); }   // milk instead of food
  function pExtra(){ return level()&gt;=6 ? Math.min(0.4, 0.16+level()*0.02) : 0; } // milk alongside food

  function reset(keepScore){
    score = keepScore ? checkpoint : 0;
    cat = { x: W*0.5, y: (areaTop()+areaBottom())/2, tx: null, ty: null,
            dir: 1, step: 0, blink: 0, pulse: 0, target: null, locked: false };
    items = []; particles = []; notes = [];
    t = 0; spawnT = 26; flash = 0;
    shownWords = Math.floor(score / PER_WORD); shownStep = growStep();
  }

  function startScoreMode(){
    scoreMode = true; state = &apos;score&apos;;
    reset(false); revealAll();
    msgEl.textContent = &apos;Mode skor tertinggi — kenyangkan dia selama mungkin!&apos;;
  }

  function note(text, color, up){
    notes.push({text:text, color:color, x:cat.x, y:cat.y-catRadius()-16, vy:up||-0.9, life:56, max:56});
  }
  function spark(x,y,c){ particles.push({x:x,y:y,vx:Math.random()*3-1.5,vy:Math.random()*3-1.5,life:16,s:2,c:c||&apos;#222&apos;}); }

  // ---- spawning ----
  function spawnPos(minFromCat){
    var pad = 26, top = areaTop()+10, bot = areaBottom()-6;
    for (var tries=0; tries&lt;40; tries++){
      var x = pad + Math.random()*(W-pad*2);
      var y = top + Math.random()*(bot-top);
      if (Math.hypot(x-cat.x, y-cat.y) &lt; minFromCat) continue;
      var clash = false;
      for (var i=0;i&lt;items.length;i++) if (Math.hypot(x-items[i].x, y-items[i].y) &lt; 54) { clash=true; break; }
      if (!clash) return {x:x,y:y};
    }
    return {x: pad+Math.random()*(W-pad*2), y: top+Math.random()*(bot-top)};
  }
  function addItem(type){
    var p = spawnPos(type===&apos;food&apos; ? 70 : 40);
    items.push({type:type, x:p.x, y:p.y, r:14, kind:Math.floor(Math.random()*3),
                life:foodLife(), max:foodLife(), bob:Math.random()*6});
  }
  function hasFood(){
    for (var i=0;i&lt;items.length;i++) if (items[i].type===&apos;food&apos;) return true;
    return false;
  }
  function spawnRound(){
    if (Math.random() &lt; pPoison()){ addItem(&apos;milk&apos;); return; } // a round with nothing safe: just wait
    addItem(&apos;food&apos;);
    if (Math.random() &lt; pExtra()) addItem(&apos;milk&apos;);
  }

  // ---- eating, dying, winning ----
  function eat(it){
    cat.pulse = 14;
    for (var i=0;i&lt;14;i++) spark(it.x, it.y, &apos;#2e7d32&apos;);
    items.splice(items.indexOf(it),1);
    cat.target = null; cat.locked = false; cat.tx = null; cat.ty = null;
    score += FOOD_SCORE;
    spawnT = spawnGap();
    if (!scoreMode){
      var st = growStep();
      if (st &gt; shownStep){ shownStep = st; note(&apos;Tumbuh! 🐱&apos;, &apos;#e0a800&apos;, -1.2); }
    }
  }

  function poisoned(it){
    items.splice(items.indexOf(it),1);
    flash = 26;
    for (var i=0;i&lt;26;i++) spark(cat.x, cat.y-catRadius(), &apos;#3b7ea1&apos;);
    die(&apos;milk&apos;);
  }

  function starve(){
    flash = 22;
    for (var i=0;i&lt;14;i++) spark(cat.x, cat.y-catRadius(), &apos;#c0392b&apos;);
    die(&apos;hunger&apos;);
  }

  function die(cause){
    if (scoreMode){
      if ((score|0) &gt; best){ best = score|0; try{ localStorage.setItem(BEST_KEY, String(best)); }catch(e){} }
      state = &apos;scoredead&apos;;
      deathMsg = cause===&apos;milk&apos; ? &apos;🥛 Susu sapi! Kucingnya diare&apos; : &apos;😿 Telat! Kucingnya kelaparan&apos;;
      msgEl.textContent = deathMsg + &apos; • Skor: &apos;+(score|0)+&apos; • Terbaik: &apos;+best;
      return;
    }
    var before = Math.floor(score / PER_WORD);
    score = Math.max(0, score - (cause===&apos;milk&apos; ? POISON_COST : STARVE_COST));
    var lost = before - Math.floor(score / PER_WORD);
    checkpoint = score;
    deathMsg = cause===&apos;milk&apos; ? &apos;🥛 Susu sapi itu racun!&apos; : &apos;😿 Makanannya hilang!&apos;;
    msgEl.textContent = deathMsg + (lost&gt;0 ? &apos; −&apos;+lost+&apos; kata&apos; : &apos;&apos;);
    state = &apos;dead&apos;;
    reveal(score); flushSave();
  }

  function win(){
    state=&apos;won&apos;; score=FINISH; revealAll();
    msgEl.textContent=&apos;Kenyang! Semua kata terbuka 🎉&apos;;
    notes = [];
    for(var i=0;i&lt;50;i++) particles.push({x:W/2+(Math.random()*W*0.4-W*0.2),y:H*0.4,
      vx:Math.random()*6-3,vy:Math.random()*-4-1,life:60,s:3,conf:true,c:&apos;#222&apos;});
  }

  // ---- input ----
  function canvasPoint(e){
    var r = cv.getBoundingClientRect();
    var p = (e.touches &amp;&amp; e.touches[0]) ||
            (e.changedTouches &amp;&amp; e.changedTouches[0]) || e; // touchend has no .touches
    return { x: (p.clientX - r.left) * (cv.width / r.width),
             y: (p.clientY - r.top) * (cv.height / r.height) };
  }
  function tap(e){
    if (e &amp;&amp; e.type!==&apos;keydown&apos;) e.preventDefault();
    if (state===&apos;ready&apos;){ state=&apos;playing&apos;; msgEl.textContent=&apos;Klik makanannya, jangan sampai hilang!&apos;; return; }
    if (state===&apos;dead&apos;){ reset(true); state=&apos;playing&apos;; msgEl.textContent=&apos;Ayo lagi — jangan telat!&apos;; reveal(score); return; }
    if (state===&apos;won&apos; || state===&apos;scoredead&apos;){ startScoreMode(); return; }
    if (state!==&apos;playing&apos; &amp;&amp; state!==&apos;score&apos;) return;
    if (!e || e.type===&apos;keydown&apos;) return;
    var p = canvasPoint(e);
    if (cat.locked) return; // already committed to the milk — no take-backs
    // did we tap an item? (generous hit radius for fingers)
    for (var i=0;i&lt;items.length;i++){
      var it = items[i];
      if (Math.hypot(p.x-it.x, p.y-it.y) &lt; it.r+16){
        cat.target = it; cat.tx = it.x; cat.ty = it.y;
        if (it.type===&apos;milk&apos;){ cat.locked = true; msgEl.textContent=&apos;🥛 Jangan…!&apos;; }
        return;
      }
    }
    // empty ground: just walk there (handy for camping where food might appear)
    cat.target = null; cat.tx = clamp(p.x,16,W-16); cat.ty = clamp(p.y,areaTop(),areaBottom());
  }

  // ---- update ----
  function update(){
    t++;
    if (flash&gt;0) flash--;
    if (cat.pulse&gt;0) cat.pulse--;
    cat.blink = (cat.blink+1) % 200;

    // walk toward the target
    if (cat.tx!=null){
      if (cat.target){ cat.tx = cat.target.x; cat.ty = cat.target.y; }
      var dx = cat.tx-cat.x, dy = cat.ty-cat.y, d = Math.hypot(dx,dy);
      var sp = catSpeed();
      if (d &gt; 1.5){
        if (Math.abs(dx) &gt; 2) cat.dir = dx&gt;0 ? 1 : -1;
        cat.x += dx/d*Math.min(sp,d); cat.y += dy/d*Math.min(sp,d);
        cat.step += 0.25;
      } else { cat.tx = cat.ty = null; }
    }

    // reached the food (or the milk)?
    if (cat.target){
      var it = cat.target;
      if (Math.hypot(it.x-cat.x, it.y-cat.y) &lt; catRadius()+it.r*0.8){
        if (it.type===&apos;food&apos;) eat(it); else { poisoned(it); return; }
      }
    }

    // item timers
    for (var i=items.length-1;i&gt;=0;i--){
      var o = items[i]; o.life--; o.bob += 0.08;
      if (o.life&lt;=0){
        var wasFood = o.type===&apos;food&apos;;
        if (cat.target===o){ cat.target=null; cat.locked=false; cat.tx=cat.ty=null; }
        items.splice(i,1);
        if (wasFood){ starve(); return; }   // milk expiring is a relief, not a loss
        spawnT = Math.min(spawnT, 18);
      }
    }

    // spawn the next round once no food is on the table
    if (!hasFood()){
      spawnT--;
      if (spawnT&lt;=0){ spawnRound(); spawnT = spawnGap(); }
    }

    for (var p=particles.length-1;p&gt;=0;p--){ var pt=particles[p]; pt.x+=pt.vx; pt.y+=pt.vy; pt.vy+=(pt.conf?0.12:0.06); pt.life--; if(pt.life&lt;=0) particles.splice(p,1); }
    for (var q=notes.length-1;q&gt;=0;q--){ var nt=notes[q]; nt.y+=nt.vy; nt.life--; if(nt.life&lt;=0) notes.splice(q,1); }

    scoreEl.textContent = score|0;
    if (!scoreMode){
      var nowWords = Math.floor(score / PER_WORD);
      if (nowWords &gt; shownWords){ note(&apos;+&apos;+(nowWords-shownWords)+&apos; kata&apos;, &apos;#2e7d32&apos;); shownWords = nowWords; }
      reveal(score);
      if (score&gt;=FINISH) win();
    }
  }

  // ---- drawing ----
  function ink2(){ ctx.strokeStyle=&apos;#222&apos;; ctx.fillStyle=&apos;#222&apos;; ctx.lineWidth=2.2; ctx.lineJoin=&apos;round&apos;; ctx.lineCap=&apos;round&apos;; }

  function drawCat(){
    var r = catRadius();
    var bounce = (cat.tx!=null) ? Math.abs(Math.sin(cat.step))*2 : Math.sin(t*0.05)*0.8;
    var pop = cat.pulse&gt;0 ? 1 + cat.pulse/60 : 1;
    ctx.save();
    ctx.translate(cat.x, cat.y - bounce);
    ctx.scale(cat.dir*pop, pop);
    ink2();

    // body
    ctx.beginPath();
    ctx.ellipse(-r*0.15, -r*0.42, r*0.78, r*0.5, 0, 0, 7);
    ctx.stroke();
    // legs
    var sw = (cat.tx!=null) ? Math.sin(cat.step)*r*0.16 : 0;
    ctx.beginPath();
    ctx.moveTo(-r*0.55, -r*0.1); ctx.lineTo(-r*0.55+sw, 0);
    ctx.moveTo(r*0.2, -r*0.1);  ctx.lineTo(r*0.2-sw, 0);
    ctx.stroke();
    // tail — wags while walking
    var wag = Math.sin(t*0.12)*r*0.25;
    ctx.beginPath();
    ctx.moveTo(-r*0.85, -r*0.5);
    ctx.quadraticCurveTo(-r*1.5, -r*0.75-wag, -r*1.25, -r*1.35-wag*0.5);
    ctx.stroke();
    // head
    var hx = r*0.62, hy = -r*0.95;
    ctx.beginPath(); ctx.arc(hx, hy, r*0.5, 0, 7); ctx.stroke();
    // ears
    ctx.beginPath();
    ctx.moveTo(hx-r*0.42, hy-r*0.28); ctx.lineTo(hx-r*0.34, hy-r*0.72); ctx.lineTo(hx-r*0.05, hy-r*0.45);
    ctx.moveTo(hx+r*0.14, hy-r*0.46); ctx.lineTo(hx+r*0.42, hy-r*0.7); ctx.lineTo(hx+r*0.46, hy-r*0.24);
    ctx.stroke();
    // eyes (blink now and then) + nose + whiskers
    if (cat.blink &gt; 6){
      ctx.beginPath();
      ctx.arc(hx-r*0.14, hy-r*0.06, Math.max(1.1,r*0.055), 0, 7);
      ctx.arc(hx+r*0.2,  hy-r*0.06, Math.max(1.1,r*0.055), 0, 7);
      ctx.fill();
    } else {
      ctx.beginPath();
      ctx.moveTo(hx-r*0.22, hy-r*0.06); ctx.lineTo(hx-r*0.06, hy-r*0.06);
      ctx.moveTo(hx+r*0.12, hy-r*0.06); ctx.lineTo(hx+r*0.28, hy-r*0.06);
      ctx.stroke();
    }
    ctx.beginPath();
    ctx.moveTo(hx+r*0.03, hy+r*0.14); ctx.lineTo(hx+r*0.1, hy+r*0.2);
    ctx.moveTo(hx+r*0.28, hy+r*0.06); ctx.lineTo(hx+r*0.62, hy-r*0.02);
    ctx.moveTo(hx+r*0.28, hy+r*0.16); ctx.lineTo(hx+r*0.6,  hy+r*0.22);
    ctx.stroke();
    ctx.restore();
  }

  function drawTimerRing(o){
    var frac = Math.max(0, o.life/o.max);
    ctx.save();
    ctx.lineWidth = 2.6; ctx.lineCap=&apos;butt&apos;;
    ctx.strokeStyle = &apos;#e6e6df&apos;;
    ctx.beginPath(); ctx.arc(o.x, o.y, o.r+9, 0, Math.PI*2); ctx.stroke();
    ctx.strokeStyle = o.type===&apos;milk&apos; ? &apos;#3b7ea1&apos; : (frac&lt;0.3 ? &apos;#c0392b&apos; : &apos;#2e7d32&apos;);
    ctx.beginPath(); ctx.arc(o.x, o.y, o.r+9, -Math.PI/2, -Math.PI/2 + Math.PI*2*frac); ctx.stroke();
    ctx.restore();
  }

  function drawItem(o){
    var y = o.y + Math.sin(o.bob)*1.6;
    drawTimerRing(o);
    ink2();
    if (o.type===&apos;milk&apos;){
      // susu kotak: a gable-top carton with a straw
      ctx.strokeStyle = &apos;#3b7ea1&apos;; ctx.fillStyle=&apos;#3b7ea1&apos;;
      ctx.beginPath();
      ctx.moveTo(o.x-9, y+11); ctx.lineTo(o.x-9, y-5);
      ctx.lineTo(o.x, y-12); ctx.lineTo(o.x+9, y-5); ctx.lineTo(o.x+9, y+11);
      ctx.closePath(); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(o.x-9, y-5); ctx.lineTo(o.x+9, y-5); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(o.x+3, y-12); ctx.lineTo(o.x+8, y-21); ctx.stroke();
      ctx.textAlign=&apos;center&apos;; ctx.font=&apos;bold &apos;+Math.round(H*0.05)+&apos;px sans-serif&apos;;
      ctx.fillText(&apos;☠&apos;, o.x-9, y-18); ctx.textAlign=&apos;left&apos;;
      return;
    }
    if (o.kind===0){ // fish
      ctx.beginPath();
      ctx.moveTo(o.x-12, y); ctx.quadraticCurveTo(o.x-2, y-9, o.x+8, y);
      ctx.quadraticCurveTo(o.x-2, y+9, o.x-12, y); ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(o.x+8, y); ctx.lineTo(o.x+15, y-6); ctx.lineTo(o.x+15, y+6); ctx.closePath(); ctx.stroke();
      ctx.beginPath(); ctx.arc(o.x-7, y-2, 1.3, 0, 7); ctx.fill();
    } else if (o.kind===1){ // drumstick — milk is the poison now, so no bowl here
      ctx.beginPath();
      ctx.moveTo(o.x+13, y+7); ctx.lineTo(o.x-1, y-3); ctx.stroke();
      ctx.beginPath();
      ctx.arc(o.x-5, y-6, 5.5, 0, 7); ctx.stroke();
      ctx.beginPath();
      ctx.arc(o.x-11, y-1, 5, 0, 7); ctx.stroke();
    } else { // a little pile of kibble
      ctx.beginPath(); ctx.arc(o.x-6, y+3, 4.5, 0, 7); ctx.stroke();
      ctx.beginPath(); ctx.arc(o.x+5, y+3, 4.5, 0, 7); ctx.stroke();
      ctx.beginPath(); ctx.arc(o.x, y-5, 4.5, 0, 7); ctx.stroke();
    }
  }

  function draw(){
    ctx.fillStyle = &apos;#fbfbf7&apos;;
    ctx.fillRect(0,0,W,H);
    ink2();

    // yard: a doodled horizon line plus scattered grass
    var top = areaTop();
    ctx.beginPath();
    for (var gx=0; gx&lt;=W; gx+=14){ var gy=top+Math.sin(gx*0.05)*1.6; if(gx===0) ctx.moveTo(gx,gy); else ctx.lineTo(gx,gy); }
    ctx.stroke();
    ctx.globalAlpha = 0.28; ctx.lineWidth = 1.6;
    for (var i=0;i&lt;tufts.length;i++){
      var tf = tufts[i];
      ctx.beginPath();
      ctx.moveTo(tf.x-tf.s, tf.y); ctx.lineTo(tf.x-tf.s*0.4, tf.y-tf.s);
      ctx.moveTo(tf.x, tf.y); ctx.lineTo(tf.x, tf.y-tf.s*1.3);
      ctx.moveTo(tf.x+tf.s, tf.y); ctx.lineTo(tf.x+tf.s*0.4, tf.y-tf.s);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;

    for (var k=0;k&lt;items.length;k++) drawItem(items[k]);

    for (var p=0;p&lt;particles.length;p++){
      var pt=particles[p];
      ctx.fillStyle = pt.c || &apos;#222&apos;;
      ctx.globalAlpha = Math.max(0, pt.life/(pt.conf?60:16));
      ctx.fillRect(pt.x, pt.y, pt.s, pt.s);
    }
    ctx.globalAlpha = 1;

    drawCat();

    // floating notifications (+N kata / Tumbuh!)
    ctx.textAlign=&apos;center&apos;;
    for (var q=0;q&lt;notes.length;q++){
      var nt=notes[q];
      ctx.globalAlpha=Math.max(0,Math.min(1, nt.life/nt.max*1.4));
      ctx.fillStyle=nt.color;
      ctx.font=&apos;bold &apos;+Math.round(H*0.07)+&apos;px sans-serif&apos;;
      ctx.fillText(nt.text, nt.x, nt.y);
    }
    ctx.globalAlpha=1; ctx.textAlign=&apos;left&apos;;

    // soft red blip on death — a fade, never a harsh strobe
    if (flash&gt;0){
      ctx.globalAlpha=(flash/26)*0.2;
      ctx.fillStyle=&apos;#c0392b&apos;;
      ctx.fillRect(0,0,W,H);
      ctx.globalAlpha=1;
    }

    // overlays — auto-shrink so text never clips on narrow screens
    ctx.textAlign=&apos;center&apos;; ctx.fillStyle=&apos;#222&apos;;
    function fit(text, px, bold){
      var pre = bold ? &apos;bold &apos; : &apos;&apos;;
      px = Math.round(px);
      ctx.font = pre+px+&apos;px sans-serif&apos;;
      while (px&gt;9 &amp;&amp; ctx.measureText(text).width &gt; W*0.9){ px--; ctx.font = pre+px+&apos;px sans-serif&apos;; }
    }
    function line(text, px, bold, y, color){
      ctx.fillStyle = color || &apos;#222&apos;;
      fit(text, px, bold); ctx.fillText(text, W/2, y);
    }
    if (state===&apos;ready&apos;){
      line(&apos;🐱 LITTLE KITTEN&apos;, H*0.16, true, H*0.4);
      line(&apos;Klik makanan → kucingnya makan &amp; tumbuh&apos;, H*0.075, false, H*0.56);
      line(&apos;🥛 Jangan pernah klik susu sapi!&apos;, H*0.075, true, H*0.68, &apos;#3b7ea1&apos;);
      line(&apos;▶ Tap untuk mulai&apos;, H*0.085, false, H*0.82);
    } else if (state===&apos;dead&apos;){
      line(deathMsg, H*0.13, true, H*0.4, &apos;#c0392b&apos;);
      line(&apos;Tap untuk lanjut menyuapi&apos;, H*0.085, false, H*0.58);
    } else if (state===&apos;won&apos;){
      line(&apos;🎉 KENYANG!&apos;, H*0.18, true, H*0.36);
      line(&apos;Semua kata terbuka — selamat membaca!&apos;, H*0.08, false, H*0.53);
      line(&apos;🏆 Tap untuk MODE SKOR TERTINGGI&apos;, H*0.08, true, H*0.68, &apos;#e0a800&apos;);
    } else if (state===&apos;scoredead&apos;){
      line(deathMsg, H*0.13, true, H*0.36, &apos;#c0392b&apos;);
      line(&apos;Skor: &apos;+(score|0)+&apos;  •  Terbaik: &apos;+best, H*0.09, false, H*0.54);
      line(&apos;Tap untuk coba lagi&apos;, H*0.08, false, H*0.68);
    }
    ctx.textAlign=&apos;left&apos;;
  }

  function loop(){
    try {
      if (state===&apos;playing&apos; || state===&apos;score&apos;) update();
      else {
        for (var p=particles.length-1;p&gt;=0;p--){ var pt=particles[p]; pt.x+=pt.vx; pt.y+=pt.vy; pt.vy+=(pt.conf?0.12:0.06); pt.life--; if(pt.life&lt;=0) particles.splice(p,1); }
        for (var q=notes.length-1;q&gt;=0;q--){ var nt=notes[q]; nt.y+=nt.vy; nt.life--; if(nt.life&lt;=0) notes.splice(q,1); }
      }
      draw();
    } catch(e){
      // the game died mid-run; don&apos;t hold the post hostage to it
      revealAll();
      msgEl.textContent = &apos;Gamenya rusak di tengah jalan — tulisannya dibuka saja.&apos;;
      return; // stop looping
    }
    requestAnimationFrame(loop);
  }

  cv.addEventListener(&apos;mousedown&apos;, tap);
  // On touch, only a real tap feeds the cat. Swiping over the yard scrolls the
  // page instead — otherwise a scroll could land on the milk and kill the run.
  var sx=0, sy=0, st=0, slid=false;
  cv.addEventListener(&apos;touchstart&apos;, function(e){
    var p = e.touches[0]; if (!p) return;
    sx = p.clientX; sy = p.clientY; st = Date.now(); slid = false;
  }, {passive:true});
  cv.addEventListener(&apos;touchmove&apos;, function(e){
    var p = e.touches[0]; if (!p) return;
    if (Math.abs(p.clientX-sx) &gt; 10 || Math.abs(p.clientY-sy) &gt; 10) slid = true;
  }, {passive:true});
  cv.addEventListener(&apos;touchend&apos;, function(e){
    if (slid || Date.now()-st &gt; 600) return;
    tap(e); // preventDefault inside tap() also cancels the synthetic click
  });
  window.addEventListener(&apos;resize&apos;, function(){ sizeCanvas(); });
  // space/enter only advances the non-playing screens; feeding needs a real aim
  window.addEventListener(&apos;keydown&apos;, function(e){
    if (e.code===&apos;Space&apos; || e.key===&apos; &apos; || e.key===&apos;Enter&apos;){
      if (state===&apos;playing&apos; || state===&apos;score&apos;) return;
      e.preventDefault(); tap(e);
    }
  });

  // ---- the two escape hatches ----
  var skipEl = document.getElementById(&apos;ink-skip&apos;);
  if (skipEl) skipEl.addEventListener(&apos;click&apos;, function(){
    revealAll();
    msgEl.textContent = &apos;Semua tulisan dibuka. Kucingnya masih boleh disuapi, kok.&apos;;
  });
  var resetEl = document.getElementById(&apos;ink-reset&apos;);
  if (resetEl) resetEl.addEventListener(&apos;click&apos;, function(){
    unlocked = false; scoreMode = false; savedWords = -1;
    try { localStorage.removeItem(SAVE_KEY); } catch(e){}
    checkpoint = 0; reset(false); state = &apos;ready&apos;;
    reveal(0);
    msgEl.textContent = &apos;Progres direset. Mulai dari nol lagi.&apos;;
  });

  try {
    sizeCanvas();
    // pick up where this reader left off
    var resume = loadProgress();
    checkpoint = resume * PER_WORD;
    reset(true);
    if (resume &gt;= TOTAL &amp;&amp; TOTAL &gt; 0){
      revealAll(); state = &apos;won&apos;;
    } else {
      reveal(score);
      if (resume &gt; 0) msgEl.textContent = &apos;Lanjut dari &apos; + resume + &apos; kata yang sudah terbuka&apos;;
    }
    loop();
  } catch(e){
    revealAll();
    msgEl.textContent = &apos;Gamenya gagal dimulai — semua tulisan dibuka.&apos;;
  }
})();
&lt;/script&gt;

</content>
 </entry>
 
 <entry>
   <title>I'm a Fucking Adult Yet I Am Learning How to Skate</title>
   <link href="https://ashura.id/skate-or-die"/>
   <updated>2026-07-21T00:00:00+00:00</updated>
   <id>https://ashura.id/skate-or-die</id>
   <content type="html">&lt;style&gt;
#skate-banner{position:sticky;top:0;z-index:50;background:#fbfbf7;border-bottom:2px solid #222;margin:0 0 8px;box-shadow:0 4px 10px rgba(0,0,0,.06);}
#skate-banner canvas{display:block;width:100%;height:auto;cursor:pointer;touch-action:manipulation;user-select:none;-webkit-user-select:none;}
#skate-banner .bar{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#555;padding:4px 10px 6px;font-family:inherit;}
#skate-banner .bar b{color:#111;}
#skate-progress{position:relative;height:4px;background:#e6e6df;}
#skate-progress &gt; i{display:block;height:100%;width:0;background:#222;transition:width .2s;}

/* invisible-ink post body: an unearned word takes up no space at all, so the
   page grows as you play instead of sitting under a tall blank slab. The real
   words stay in the markup so crawlers and readers without JS still get the
   post; display:none is enough to keep them out of a select-all copy. */
#ink .w{display:none;}
#ink .w.on{display:inline;animation:inkfade .45s ease both;}
@keyframes inkfade{from{opacity:0}to{opacity:1}}
#ink .blk-off{display:none;} /* paragraphs, headings and rules with nothing in them yet */
#ink{min-height:1rem;}
/* Escape hatch for anyone who cannot play: off-screen until focused, so Tab
   reaches it and a screen reader announces it, without tempting a mouse. */
.ink-skip{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;}
.ink-skip:focus{position:static;width:auto;height:auto;display:inline-block;margin:6px 0 2px;padding:6px 10px;font-size:13px;border:1.5px dashed #bbb;border-radius:8px;background:#fbfbf7;color:#222;}
.ink-tools{margin:4px 0 0;text-align:right;}
.ink-reset{font:inherit;font-size:11px;color:#aaa;background:none;border:0;padding:2px 4px;cursor:pointer;text-decoration:underline dotted;}
.ink-reset:hover,.ink-reset:focus{color:#555;}
&lt;/style&gt;

&lt;div id=&quot;skate-banner&quot;&gt;
  &lt;canvas id=&quot;skate-canvas&quot; width=&quot;720&quot; height=&quot;300&quot;&gt;&lt;/canvas&gt;
  &lt;div id=&quot;skate-progress&quot;&gt;&lt;i&gt;&lt;/i&gt;&lt;/div&gt;
  &lt;div class=&quot;bar&quot;&gt;
    &lt;span&gt;🛹 &lt;b id=&quot;sk-words&quot;&gt;0&lt;/b&gt; / &lt;b id=&quot;sk-total&quot;&gt;0&lt;/b&gt; kata terbuka&lt;/span&gt;
    &lt;span id=&quot;sk-msg&quot;&gt;Tap / Spasi untuk mulai meluncur&lt;/span&gt;
    &lt;span&gt;Skor: &lt;b id=&quot;sk-score&quot;&gt;0&lt;/b&gt;&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;p class=&quot;ink-tools&quot;&gt;
  &lt;a href=&quot;#ink&quot; class=&quot;ink-skip&quot; id=&quot;ink-skip&quot;&gt;Lewati permainan — tampilkan semua tulisan&lt;/a&gt;
  &lt;button type=&quot;button&quot; class=&quot;ink-reset&quot; id=&quot;ink-reset&quot;&gt;⟲ reset progres&lt;/button&gt;
&lt;/p&gt;

&lt;div id=&quot;ink&quot;&gt;

  &lt;p&gt;&lt;em&gt;Umur 26, dan saya baru belajar skate. Tulisan di bawah ini baru muncul kalau kamu ikut meluncur di atas.&lt;/em&gt;&lt;/p&gt;

  &lt;hr /&gt;

  &lt;h3 id=&quot;mei-2026&quot;&gt;Mei 2026&lt;/h3&gt;

  &lt;p&gt;Bulan Mei saya beli longboard. Sebulan kemudian, saya dapat surfskate gratis. Tidak ada momen pencerahan atau habis nonton film apa pun. Saya cuma ingin bisa, jadi ya mulai saja.&lt;/p&gt;

  &lt;p&gt;Aspal ternyata guru yang galak. Kerja di mana, gaji berapa, sudah berapa tahun pengalaman, ia tidak peduli. Salah sedikit, dengkul lecet, bayar di tempat.&lt;/p&gt;

  &lt;blockquote&gt;
    &lt;p&gt;&lt;em&gt;Aduh. Dengkul lagi.&lt;/em&gt;&lt;/p&gt;
  &lt;/blockquote&gt;

  &lt;p&gt;Lama-lama saya sudah cukup sering jatuh sampai tidak kaget lagi. Jatuh ya jatuh, nanti juga bangun.&lt;/p&gt;

  &lt;h3 id=&quot;kalender&quot;&gt;Kalender&lt;/h3&gt;

  &lt;p&gt;Agustus nanti rencananya surfing. Jatuhnya pindah ke air, setidaknya tidak lecet.&lt;/p&gt;

  &lt;p&gt;Tahun 2027, main salju di Myoko, lalu Hokkaido. Jatuh lagi, kali ini di Jepang.&lt;/p&gt;

  &lt;p&gt;Longboard, surfskate, ombak, salju. Kalau dipikir-pikir intinya sama saja: berdiri di atas sesuatu yang goyang dan tetap mau ke arah tertentu. Mumpung badan ini masih mau diajak begini.&lt;/p&gt;

  &lt;h3 id=&quot;dua-jenis-waktu&quot;&gt;Dua jenis waktu&lt;/h3&gt;

  &lt;p&gt;Adik saya sebentar lagi menikah. Saya ikut senang, beneran. Dia tahu apa yang dia mau dari awal dan serius menjalaninya, dan menurut saya itu keren.&lt;/p&gt;

  &lt;p&gt;Waktu saya kebanyakan habis di tempat lain: Kyrgyzstan, Kazakhstan, Uzbekistan, China, Nagano. Naik kuda, lewat jalan yang belum diaspal, di tempat yang bahasanya tidak saya mengerti. Saya tidak sedang kabur dari apa-apa. Saya hanya tidak pernah setuju kalau urutan hidup harus ditentukan orang lain.&lt;/p&gt;

  &lt;p&gt;Jadi dewasa, buat saya, bukan soal mencentang daftar satu per satu.&lt;/p&gt;

  &lt;h3 id=&quot;tidak-ada-papan-skor&quot;&gt;Tidak ada papan skor&lt;/h3&gt;

  &lt;p&gt;Kadang saya berpikir, toh tidak ada yang menghitung juga. Tidak ada juri yang mencatat siapa yang duluan dan siapa yang telat. Ujung-ujungnya semua juga akan jadi debu.&lt;/p&gt;

  &lt;p&gt;Anehnya, itu malah bikin lega. Kalau memang tidak ada yang mengawasi, berarti tidak ada juga yang berhak bilang hidup saya salah urutan.&lt;/p&gt;

  &lt;p&gt;Jadi ya sudah, saya main lagi. Jatuh, bangun, cek dengkul, lanjut.&lt;/p&gt;

  &lt;p&gt;Makasih sudah ikut meluncur sampai sini. Kalau nanti kita ketemu di trotoar dan saya jatuh, boleh kok ketawa. Asif, &lt;em&gt;signing off.&lt;/em&gt; 🛹&lt;/p&gt;

&lt;/div&gt;

&lt;script&gt;
(function(){
  // ---- reveal setup: wrap every word in the post body into a span ----
  var ink = document.getElementById(&apos;ink&apos;);
  var words = [];

  (function walk(node){
    // snapshot children first: we mutate the tree as we go, and appending a
    // DocumentFragment empties it, so a live childNodes loop would re-process
    // the spans we just created (infinite loop).
    var kids = Array.prototype.slice.call(node.childNodes);
    for (var k=0;k&lt;kids.length;k++){
      var n = kids[k];
      if (n.nodeType===3){ // text node
        if (!/\S/.test(n.nodeValue)) continue; // whitespace-only, leave as is
        var parts = n.nodeValue.split(/(\s+)/);
        var frag = document.createDocumentFragment();
        for (var p=0;p&lt;parts.length;p++){
          if (/\S/.test(parts[p])){
            var s = document.createElement(&apos;span&apos;);
            s.className=&apos;w&apos;; s.textContent = parts[p];
            s._wi = words.length; // its index, for the block bookkeeping below
            frag.appendChild(s); words.push(s);
          } else if (parts[p]){
            frag.appendChild(document.createTextNode(parts[p]));
          }
        }
        node.replaceChild(frag, n);
      } else if (n.nodeType===1){ // element (e.g. &lt;em&gt;) — recurse, never a created span
        walk(n);
      }
    }
  })(ink);

  // Each top-level block remembers the index of its first word, so it can stay
  // collapsed until that word is earned. Words are revealed as a prefix, so
  // &quot;first word &lt; revealed count&quot; is exactly the right test. A block with no
  // words of its own (a horizontal rule) borrows the threshold of the next block
  // that has some, and so appears with the section it introduces.
  var blocks = [];
  (function(){
    var kids = [];
    for (var c=0;c&lt;ink.childNodes.length;c++) if (ink.childNodes[c].nodeType===1) kids.push(ink.childNodes[c]);
    for (var b=0;b&lt;kids.length;b++){
      var first = -1;
      (function scan(node){
        for (var i=0;i&lt;node.childNodes.length;i++){
          var n = node.childNodes[i];
          if (n.nodeType!==1) continue;
          if (typeof n._wi === &apos;number&apos;){ if (first&lt;0 || n._wi&lt;first) first = n._wi; }
          else scan(n);
        }
      })(kids[b]);
      blocks.push({ el:kids[b], at:first, on:null });
    }
    var next = Infinity;
    for (var k=blocks.length-1;k&gt;=0;k--){
      if (blocks[k].at &lt; 0) blocks[k].at = next; else next = blocks[k].at;
    }
  })();
  function syncBlocks(n){
    for (var b=0;b&lt;blocks.length;b++){
      var on = blocks[b].at &lt; n;
      if (blocks[b].on === on) continue;
      blocks[b].on = on;
      if (on) blocks[b].el.classList.remove(&apos;blk-off&apos;);
      else blocks[b].el.classList.add(&apos;blk-off&apos;);
    }
  }

  var TOTAL = words.length;
  // a flawless run should take ~20s no matter how long the post gets
  var PER_WORD = Math.max(1, 700 / TOTAL);
  var FINISH = TOTAL * PER_WORD;
  var PENALTY = FINISH * 0.30; // crashing chops 30% of the whole post
  var RAMP_BONUS = PER_WORD * 6; // nailing a ramp launch grants 6 words
  var NOTE_CHUNK = 3; // words per popup — one popup per word machine-guns
  document.getElementById(&apos;sk-total&apos;).textContent = TOTAL;

  var BEST_KEY = &apos;ashura_skate_highscore&apos;;
  var best = 0;
  try { best = parseInt(localStorage.getItem(BEST_KEY) || &apos;0&apos;, 10) || 0; } catch(e){}

  // ---- how much of the post this reader has already earned ----
  // Stored as a word count, not a score, so it survives edits to the prose.
  var SAVE_KEY = &apos;ashura_ink_&apos; + ((window.location &amp;&amp; window.location.pathname) || &apos;skate&apos;);
  var savedWords = -1, pendingWords = -1, lastSave = 0;
  function flushSave(){
    if (pendingWords &lt; 0 || pendingWords === savedWords) return;
    savedWords = pendingWords; lastSave = Date.now();
    try { localStorage.setItem(SAVE_KEY, String(savedWords)); } catch(e){}
  }
  function persist(n, force){
    pendingWords = n; // words land several per second; write at most every 250ms
    if (force || Date.now() - lastSave &gt;= 250) flushSave();
  }
  // whatever the throttle is still holding must land before the reader leaves
  window.addEventListener(&apos;pagehide&apos;, flushSave);
  window.addEventListener(&apos;blur&apos;, flushSave);
  document.addEventListener(&apos;visibilitychange&apos;, flushSave);

  function loadProgress(){
    var n = 0;
    try { n = parseInt(localStorage.getItem(SAVE_KEY) || &apos;0&apos;, 10) || 0; } catch(e){}
    return Math.max(0, Math.min(TOTAL, n));
  }

  var unlocked = false; // set by the skip link or a win: stop re-hiding words

  var wordsEl = document.getElementById(&apos;sk-words&apos;);
  var progEl = document.querySelector(&apos;#skate-progress &gt; i&apos;);
  function show(i, on){
    if (!!words[i]._on === !!on) return; // already in the right state
    words[i]._on = on;
    if (on) words[i].classList.add(&apos;on&apos;); else words[i].classList.remove(&apos;on&apos;);
  }
  function reveal(score){
    if (unlocked) return TOTAL; // nothing gets taken back once the post is open
    var n = Math.min(TOTAL, Math.floor(score / PER_WORD));
    for (var i=0;i&lt;TOTAL;i++) show(i, i&lt;n);
    syncBlocks(n);
    wordsEl.textContent = n;
    progEl.style.width = Math.min(100,(score/FINISH)*100)+&apos;%&apos;;
    persist(n);
    return n;
  }
  function revealAll(){
    unlocked = true;
    for (var i=0;i&lt;TOTAL;i++) show(i, true);
    syncBlocks(TOTAL);
    wordsEl.textContent = TOTAL; progEl.style.width = &apos;100%&apos;;
    persist(TOTAL, true);
  }

  // ---- the game (white, doodle style) ----
  var cv = document.getElementById(&apos;skate-canvas&apos;);
  var ctx = cv &amp;&amp; cv.getContext &amp;&amp; cv.getContext(&apos;2d&apos;);
  var W = cv ? cv.width : 0, H = cv ? cv.height : 0, GROUND;
  var scoreEl = document.getElementById(&apos;sk-score&apos;);
  var msgEl = document.getElementById(&apos;sk-msg&apos;);

  // No canvas (blocked, ancient browser) means no way to earn the words, so give
  // them away rather than leaving an unreadable page behind.
  if (!ctx){
    revealAll();
    msgEl.textContent = &apos;Gamenya tidak bisa jalan di sini — semua tulisan dibuka.&apos;;
    return;
  }

  function sizeCanvas(){
    var cssW = cv.clientWidth || cv.parentElement.clientWidth || 720;
    // taller aspect so a double-jump never clips off the top; capped so the
    // sticky banner never eats the whole viewport.
    var h = Math.max(220, Math.min(340, cssW*0.42));
    cv.width = W = Math.round(cssW);
    cv.height = H = Math.round(h);
    GROUND = H - 34;
    if (player) player.y = Math.min(player.y, GROUND);
  }

  var state = &apos;ready&apos;; // ready | playing | won | score | scoredead
  var scoreMode = false;
  var player, obstacles, particles, notes, t, speed, spawnT, score, holdBoost, checkpoint, flash, shownWords;

  function reset(keepScore){
    var s = keepScore ? checkpoint : 0;
    player = { x: 70, y: GROUND, vy:0, r:12, rot:0, onGround:true, jumps:0 };
    obstacles = []; particles = []; notes = [];
    t = 0; speed = 4.2; spawnT = 40;
    score = s; holdBoost = 0; flash = 0; shownWords = Math.floor(s / PER_WORD);
  }

  function startScoreMode(){
    scoreMode = true; state = &apos;score&apos;;
    reset(false); revealAll();
    msgEl.textContent = &apos;Mode skor tertinggi — bertahan selama mungkin!&apos;;
  }

  function note(text, color, up){
    notes.push({text:text, color:color, x:player.x+18, y:player.y-player.r-18, vy:up||-0.9, life:52, max:52});
  }

  var GRAV = 0.6, JUMP = -9.2, DOUBLE = -7.8;

  function jump(){
    if (state===&apos;ready&apos;){ state=&apos;playing&apos;; msgEl.textContent=&apos;Meluncur! Lompati rintangannya.&apos;; return; }
    if (state===&apos;won&apos; || state===&apos;scoredead&apos;){ startScoreMode(); return; }
    if (player.onGround){
      // launch off a ramp if you jump right at its lip → bonus words
      var ramp=null;
      for (var i=0;i&lt;obstacles.length;i++){ var o=obstacles[i];
        if (o.type===&apos;ramp&apos; &amp;&amp; !o.used &amp;&amp; player.x &gt; o.x+o.w*0.45 &amp;&amp; player.x &lt; o.x+o.w+14){ ramp=o; break; } }
      if (ramp){ rampBonus(ramp); player.vy=JUMP*1.5; holdBoost=12; }
      else { player.vy=JUMP; holdBoost=9; }
      player.onGround=false; player.jumps=1; puff(player.x,player.y+player.r);
    }
    else if (player.jumps&lt;2){ player.vy=DOUBLE; player.jumps=2; holdBoost=6; for(var i=0;i&lt;6;i++) spark(player.x,player.y,&apos;#222&apos;); }
  }
  function releaseJump(){ holdBoost=0; }

  function rampBonus(o){
    o.used=true;
    score += RAMP_BONUS;
    shownWords = Math.floor(score / PER_WORD); // suppress the plain +N note; show AWESOME instead
    note(&apos;AWESOME! +&apos;+(RAMP_BONUS/PER_WORD)+&apos; kata&apos;, &apos;#e0a800&apos;, -1.3);
    msgEl.textContent=&apos;🤩 AWESOME! Bonus &apos;+(RAMP_BONUS/PER_WORD)+&apos; kata!&apos;;
    for (var i=0;i&lt;24;i++) spark(player.x, player.y-player.r, &apos;#e0a800&apos;);
  }

  function puff(x,y){ for(var i=0;i&lt;5;i++) particles.push({x:x+(Math.random()*14-7),y:y,vx:-speed*0.5-Math.random(),vy:Math.random()*-1,life:18,s:2}); }
  function spark(x,y,c){ particles.push({x:x,y:y,vx:Math.random()*3-1.5,vy:Math.random()*3-1.5,life:14,s:2}); }

  function spawnObstacle(){
    var roll = Math.random();
    if (roll&lt;0.12) obstacles.push({type:&apos;ramp&apos;, x:W+20, y:GROUND, w:52, h:30, used:false});
    else if (roll&lt;0.44) obstacles.push({type:&apos;rock&apos;, x:W+20, y:GROUND, w:24, h:18});
    else if (roll&lt;0.7) obstacles.push({type:&apos;box&apos;, x:W+20, y:GROUND, w:22, h:30});
    else if (roll&lt;0.88){ obstacles.push({type:&apos;cone&apos;, x:W+20, y:GROUND, w:18, h:22}); obstacles.push({type:&apos;cone&apos;, x:W+50, y:GROUND, w:18, h:22}); }
    else obstacles.push({type:&apos;bird&apos;, x:W+20, y:GROUND-58, w:24, h:14, flap:0});
  }

  function hit(){
    flash = 22;
    for(var i=0;i&lt;18;i++) spark(player.x,player.y,&apos;#c0392b&apos;);
    if (scoreMode){ // endless run ends; record best
      if ((score|0) &gt; best){ best = score|0; try{localStorage.setItem(BEST_KEY, String(best));}catch(e){} }
      state = &apos;scoredead&apos;;
      msgEl.textContent = &apos;💥 Jatuh! Skor: &apos;+(score|0)+&apos; • Terbaik: &apos;+best;
      return;
    }
    var before = Math.floor(score / PER_WORD);
    score = Math.max(0, score - PENALTY); // lose 10 words for crashing
    var lost = before - Math.floor(score / PER_WORD);
    checkpoint = score;
    msgEl.textContent = lost&gt;0 ? (&apos;💥 Nabrak! −&apos;+lost+&apos; kata&apos;) : &apos;💥 Nabrak!&apos;;
    var px=player.x, py=player.y, pr=player.r;
    reset(true);
    notes.push({text: lost&gt;0 ? (&apos;−&apos;+lost+&apos; kata&apos;) : &apos;Nabrak!&apos;, color:&apos;#c0392b&apos;, x:px+18, y:py-pr-18, vy:-0.9, life:70, max:70});
    state=&apos;playing&apos;;
    reveal(score); flushSave();
  }

  function win(){
    state=&apos;won&apos;; score=FINISH; revealAll();
    msgEl.textContent=&apos;Selesai! Semua kata terbuka 🎉&apos;;
    notes = [];
    for(var i=0;i&lt;50;i++) particles.push({x:W/2+(Math.random()*W*0.4-W*0.2),y:H*0.4,vx:Math.random()*6-3,vy:Math.random()*-4-1,life:60,s:3,conf:true});
  }

  function update(){
    t++; speed += 0.0022;
    if (flash&gt;0) flash--;

    if (holdBoost&gt;0 &amp;&amp; !player.onGround &amp;&amp; player.vy&lt;0){ player.vy-=0.4; holdBoost--; }
    player.vy += GRAV; player.y += player.vy;
    if (player.y&gt;=GROUND){ player.y=GROUND; player.vy=0; player.onGround=true; player.jumps=0; }
    player.rot = player.onGround ? 0 : player.rot+0.2;

    spawnT--; if (spawnT&lt;=0){ spawnObstacle(); spawnT = Math.max(30, 66 - t*0.02) + Math.random()*24; }

    for (var i=obstacles.length-1;i&gt;=0;i--){
      var o=obstacles[i]; o.x-=speed;
      if (o.type===&apos;bird&apos;){ o.flap+=0.3; o.y=GROUND-58+Math.sin(o.flap)*7; }
      if (o.type!==&apos;ramp&apos;){ // ramps are launch pads, not lethal
        var top=o.y-o.h;
        var cx=Math.max(o.x,Math.min(player.x,o.x+o.w));
        var cy=Math.max(top,Math.min(player.y,o.y));
        var dx=player.x-cx, dy=player.y-cy;
        if (dx*dx+dy*dy &lt; (player.r-1)*(player.r-1)){ hit(); return; }
      }
      if (o.x+o.w&lt;-10){ obstacles.splice(i,1); if (o.type!==&apos;ramp&apos;) score += 4; }
    }

    // ride up ramps instead of passing through them (only while grounded/falling,
    // so an intentional jump still launches off the lip)
    if (player.vy &gt;= 0){
      for (var rr=0;rr&lt;obstacles.length;rr++){ var rp=obstacles[rr];
        if (rp.type===&apos;ramp&apos; &amp;&amp; player.x&gt;=rp.x &amp;&amp; player.x&lt;=rp.x+rp.w){
          var surf = rp.y - rp.h*((player.x-rp.x)/rp.w);
          if (player.y &gt;= surf){ player.y=surf; player.vy=0; player.onGround=true; player.jumps=0; player.rot=-Math.atan2(rp.h,rp.w); }
        }
      }
    }

    for (var p=particles.length-1;p&gt;=0;p--){ var pt=particles[p]; pt.x+=pt.vx; pt.y+=pt.vy; pt.vy+=(pt.conf?0.12:0.08); pt.life--; if(pt.life&lt;=0) particles.splice(p,1); }
    for (var q=notes.length-1;q&gt;=0;q--){ var nt=notes[q]; nt.y+=nt.vy; nt.life--; if(nt.life&lt;=0) notes.splice(q,1); }

    score += speed*0.09; // distance skated
    scoreEl.textContent = score|0;
    if (!scoreMode){
      var nowWords = Math.floor(score / PER_WORD);
      var gained = nowWords - shownWords;
      if (gained &gt;= NOTE_CHUNK || (gained &gt; 0 &amp;&amp; nowWords &gt;= TOTAL)){
        note(&apos;+&apos;+gained+&apos; kata&apos;, &apos;#2e7d32&apos;); shownWords = nowWords;
      }
      reveal(score);
      if (score&gt;=FINISH) win();
    }
  }

  function ink2(){ ctx.strokeStyle=&apos;#222&apos;; ctx.fillStyle=&apos;#222&apos;; ctx.lineWidth=2.4; ctx.lineJoin=&apos;round&apos;; ctx.lineCap=&apos;round&apos;; }

  function drawPlayer(){
    ctx.save(); ctx.translate(player.x, player.y-player.r); ctx.rotate(player.rot); ink2();
    // deck
    ctx.beginPath(); ctx.moveTo(-16,player.r); ctx.lineTo(16,player.r); ctx.stroke();
    // wheels
    ctx.beginPath(); ctx.arc(-9,player.r+4,2.4,0,7); ctx.arc(9,player.r+4,2.4,0,7); ctx.stroke();
    // head
    ctx.beginPath(); ctx.arc(0,-2,player.r-4,0,7); ctx.stroke();
    // little smile
    ctx.beginPath(); ctx.arc(0,-2,4,0.15*Math.PI,0.85*Math.PI); ctx.stroke();
    ctx.restore();
  }

  function draw(){
    ctx.fillStyle = &apos;#fbfbf7&apos;;
    ctx.fillRect(0,0,W,H);
    ink2();

    // ground — slightly wavy doodle line
    ctx.beginPath();
    for (var gx=0; gx&lt;=W; gx+=12){ var gy=GROUND+Math.sin((gx+t*speed)*0.05)*1.2; if(gx===0) ctx.moveTo(gx,gy); else ctx.lineTo(gx,gy); }
    ctx.stroke();
    // dashes
    var off=(t*speed)%36;
    for(var d=-1; d*36-off&lt;W; d++){ ctx.beginPath(); ctx.moveTo(d*36-off+8,GROUND+12); ctx.lineTo(d*36-off+24,GROUND+12); ctx.stroke(); }

    // obstacles (doodle outlines)
    for (var i=0;i&lt;obstacles.length;i++){
      var o=obstacles[i]; ink2();
      if (o.type===&apos;ramp&apos;){
        // up-ramp rising to the right, with a gold star at the lip
        ctx.beginPath(); ctx.moveTo(o.x,o.y); ctx.lineTo(o.x+o.w,o.y-o.h); ctx.lineTo(o.x+o.w,o.y); ctx.closePath(); ctx.stroke();
        ctx.fillStyle=&apos;#e0a800&apos;; ctx.textAlign=&apos;center&apos;;
        ctx.font=&apos;bold &apos;+Math.round(H*0.075)+&apos;px sans-serif&apos;;
        ctx.fillText(&apos;★&apos;, o.x+o.w, o.y-o.h-4);
        ctx.textAlign=&apos;left&apos;;
      } else if (o.type===&apos;rock&apos;){
        ctx.beginPath(); ctx.moveTo(o.x,o.y); ctx.quadraticCurveTo(o.x+2,o.y-o.h,o.x+o.w*0.45,o.y-o.h);
        ctx.quadraticCurveTo(o.x+o.w,o.y-o.h*0.8,o.x+o.w,o.y); ctx.closePath(); ctx.stroke();
      } else if (o.type===&apos;box&apos;){
        ctx.strokeRect(o.x,o.y-o.h,o.w,o.h);
        ctx.beginPath(); ctx.moveTo(o.x,o.y-o.h); ctx.lineTo(o.x+o.w,o.y); ctx.moveTo(o.x+o.w,o.y-o.h); ctx.lineTo(o.x,o.y); ctx.stroke();
      } else if (o.type===&apos;cone&apos;){
        ctx.beginPath(); ctx.moveTo(o.x,o.y); ctx.lineTo(o.x+o.w/2,o.y-o.h); ctx.lineTo(o.x+o.w,o.y); ctx.closePath(); ctx.stroke();
        ctx.beginPath(); ctx.moveTo(o.x+3,o.y-o.h*0.5); ctx.lineTo(o.x+o.w-3,o.y-o.h*0.5); ctx.stroke();
      } else if (o.type===&apos;bird&apos;){
        var wy=Math.sin(o.flap)*6;
        ctx.beginPath(); ctx.arc(o.x+o.w/2,o.y-o.h/2,o.h/2,0,7); ctx.stroke();
        ctx.beginPath(); ctx.moveTo(o.x+o.w/2,o.y-o.h/2); ctx.lineTo(o.x,o.y-o.h/2-wy-6);
        ctx.moveTo(o.x+o.w/2,o.y-o.h/2); ctx.lineTo(o.x+o.w,o.y-o.h/2-wy-6); ctx.stroke();
      }
    }

    // particles
    ctx.fillStyle=&apos;#222&apos;;
    for (var p=0;p&lt;particles.length;p++){ var pt=particles[p]; ctx.globalAlpha=Math.max(0,pt.life/(pt.conf?60:18)); ctx.fillRect(pt.x,pt.y,pt.s,pt.s); }
    ctx.globalAlpha=1;

    drawPlayer();

    // floating notifications (+1 kata / −10 kata)
    ctx.textAlign=&apos;center&apos;;
    for (var q=0;q&lt;notes.length;q++){
      var nt=notes[q];
      ctx.globalAlpha=Math.max(0,Math.min(1, nt.life/nt.max*1.4));
      ctx.fillStyle=nt.color;
      ctx.font=&apos;bold &apos;+Math.round(H*0.075)+&apos;px sans-serif&apos;;
      ctx.fillText(nt.text, nt.x, nt.y);
    }
    ctx.globalAlpha=1; ctx.textAlign=&apos;left&apos;;

    // subtle red blip on crash — a soft fade, never a harsh strobe
    if (flash&gt;0){
      ctx.globalAlpha=(flash/22)*0.22;
      ctx.fillStyle=&apos;#c0392b&apos;;
      ctx.fillRect(0,0,W,H);
      ctx.globalAlpha=1;
    }

    // overlays — auto-shrink so text never clips on narrow screens
    ctx.textAlign=&apos;center&apos;; ctx.fillStyle=&apos;#222&apos;;
    function fit(text, px, bold){
      var pre = bold ? &apos;bold &apos; : &apos;&apos;;
      px = Math.round(px);
      ctx.font = pre+px+&apos;px sans-serif&apos;;
      while (px&gt;9 &amp;&amp; ctx.measureText(text).width &gt; W*0.9){ px--; ctx.font = pre+px+&apos;px sans-serif&apos;; }
    }
    if (state===&apos;ready&apos;){
      fit(&quot;🛹 I&apos;M A FUCKING ADULT&quot;, H*0.15, true); ctx.fillText(&quot;🛹 I&apos;M A FUCKING ADULT&quot;, W/2, H*0.34);
      fit(&apos;yet I am learning how to skate&apos;, H*0.11, false); ctx.fillText(&apos;yet I am learning how to skate&apos;, W/2, H*0.52);
      fit(&apos;▶ Tap / Spasi untuk meluncur&apos;, H*0.095, false); ctx.fillText(&apos;▶ Tap / Spasi untuk meluncur&apos;, W/2, H*0.72);
    } else if (state===&apos;won&apos;){
      fit(&apos;🎉 SELAMAT!&apos;, H*0.2, true); ctx.fillText(&apos;🎉 SELAMAT!&apos;, W/2, H*0.38);
      fit(&apos;Semua kata terbuka — selamat membaca!&apos;, H*0.088, false); ctx.fillText(&apos;Semua kata terbuka — selamat membaca!&apos;, W/2, H*0.55);
      ctx.fillStyle=&apos;#e0a800&apos;; fit(&apos;🏆 Tap untuk MODE SKOR TERTINGGI&apos;, H*0.088, true); ctx.fillText(&apos;🏆 Tap untuk MODE SKOR TERTINGGI&apos;, W/2, H*0.7);
    } else if (state===&apos;scoredead&apos;){
      ctx.fillStyle=&apos;#c0392b&apos;; fit(&apos;💥 JATUH!&apos;, H*0.2, true); ctx.fillText(&apos;💥 JATUH!&apos;, W/2, H*0.38);
      ctx.fillStyle=&apos;#222&apos;; fit(&apos;Skor: &apos;+(score|0)+&apos;  •  Terbaik: &apos;+best, H*0.1, false); ctx.fillText(&apos;Skor: &apos;+(score|0)+&apos;  •  Terbaik: &apos;+best, W/2, H*0.56);
      fit(&apos;Tap untuk coba lagi&apos;, H*0.09, false); ctx.fillText(&apos;Tap untuk coba lagi&apos;, W/2, H*0.7);
    }
    ctx.textAlign=&apos;left&apos;;
  }

  function loop(){
    try {
      if (state===&apos;playing&apos; || state===&apos;score&apos;) update();
      else { for (var p=particles.length-1;p&gt;=0;p--){ var pt=particles[p]; pt.x+=pt.vx; pt.y+=pt.vy; pt.vy+=(pt.conf?0.12:0.08); pt.life--; if(pt.life&lt;=0) particles.splice(p,1);} }
      draw();
    } catch(e){
      // the game died mid-run; don&apos;t hold the post hostage to it
      revealAll();
      msgEl.textContent = &apos;Gamenya rusak di tengah jalan — tulisannya dibuka saja.&apos;;
      return; // stop looping
    }
    requestAnimationFrame(loop);
  }

  function press(e){ if(e &amp;&amp; e.type!==&apos;keydown&apos;) e.preventDefault(); jump(); }
  cv.addEventListener(&apos;mousedown&apos;, press);
  cv.addEventListener(&apos;touchstart&apos;, press, {passive:false});
  window.addEventListener(&apos;mouseup&apos;, releaseJump);
  window.addEventListener(&apos;touchend&apos;, releaseJump);
  window.addEventListener(&apos;keydown&apos;, function(e){ if(e.code===&apos;Space&apos;||e.key===&apos; &apos;||e.key===&apos;ArrowUp&apos;){ e.preventDefault(); press(e); }});
  window.addEventListener(&apos;keyup&apos;, function(e){ if(e.code===&apos;Space&apos;||e.key===&apos; &apos;||e.key===&apos;ArrowUp&apos;){ releaseJump(); }});
  window.addEventListener(&apos;resize&apos;, function(){ sizeCanvas(); });

  // tap ANYWHERE on the page to jump (click doesn&apos;t fire on scroll, so it
  // won&apos;t hijack scrolling). Skip links/buttons and the canvas (handled above).
  document.addEventListener(&apos;click&apos;, function(e){
    if (e.target===cv || (e.target.closest &amp;&amp; e.target.closest(&apos;a,button,input,textarea,select,label&apos;))) return;
    jump(); releaseJump();
  });

  // ---- the two escape hatches ----
  var skipEl = document.getElementById(&apos;ink-skip&apos;);
  if (skipEl) skipEl.addEventListener(&apos;click&apos;, function(){
    revealAll();
    msgEl.textContent = &apos;Semua tulisan dibuka. Papannya masih boleh dipakai, kok.&apos;;
  });
  var resetEl = document.getElementById(&apos;ink-reset&apos;);
  if (resetEl) resetEl.addEventListener(&apos;click&apos;, function(){
    unlocked = false; scoreMode = false; savedWords = -1;
    try { localStorage.removeItem(SAVE_KEY); } catch(e){}
    checkpoint = 0; reset(false); state = &apos;ready&apos;;
    reveal(0);
    msgEl.textContent = &apos;Progres direset. Mulai dari nol lagi.&apos;;
  });

  try {
    sizeCanvas();
    // pick up where this reader left off
    var resume = loadProgress();
    checkpoint = resume * PER_WORD;
    reset(true);
    if (resume &gt;= TOTAL &amp;&amp; TOTAL &gt; 0){
      revealAll(); state = &apos;won&apos;;
    } else {
      reveal(score);
      if (resume &gt; 0) msgEl.textContent = &apos;Lanjut dari &apos; + resume + &apos; kata yang sudah terbuka&apos;;
    }
    loop();
  } catch(e){
    revealAll();
    msgEl.textContent = &apos;Gamenya gagal dimulai — semua tulisan dibuka.&apos;;
  }
})();
&lt;/script&gt;

</content>
 </entry>
 
 <entry>
   <title>Kembali</title>
   <link href="https://ashura.id/kembali"/>
   <updated>2025-01-05T00:00:00+00:00</updated>
   <id>https://ashura.id/kembali</id>
   <content type="html">&lt;style&gt;
.toy{margin:1.4rem auto 1.1rem;max-width:460px;}
.toy canvas{display:block;width:100%;height:auto;background:#fbfbf7;border:1.5px dashed #dcdcd2;border-radius:10px;cursor:pointer;touch-action:pan-y;user-select:none;-webkit-user-select:none;}
.toy .hint{text-align:center;font-size:11px;color:#aaa;margin-top:3px;letter-spacing:.02em;transition:opacity .4s;}
.toy.touched .hint{opacity:0;}
.toy-swap{max-width:460px;margin:1.4rem auto 1.1rem;border:1.5px dashed #dcdcd2;border-radius:10px;padding:14px 16px;cursor:pointer;background:#fbfbf7;user-select:none;-webkit-user-select:none;}
.toy-swap .tag{display:inline-block;font-size:10px;font-weight:bold;letter-spacing:.08em;padding:2px 7px;border-radius:99px;background:#222;color:#fbfbf7;margin-bottom:8px;}
.toy-swap .sentence{font-size:15px;line-height:1.5;color:#222;}
.toy-swap .hint{font-size:11px;color:#aaa;margin-top:8px;}
&lt;/style&gt;

&lt;p&gt;&lt;em&gt;It’s been over ten years since I first stepped into high school.&lt;/em&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;waktu&quot;&gt;Waktu…&lt;/h3&gt;

&lt;p&gt;Waktu berjalan cepat. Rasanya, baru kemarin saya mengenakan seragam putih abu-abu untuk pertama kalinya, mengikuti Masa Orientasi Siswa. Tiga hari yang melelahkan, penuh tugas yang menghabiskan waktu 4-6 jam setiap malam, setelah seharian penuh kegiatan yang baru selesai menjelang shalat Isya.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;“Keputusan kepala sekolah: karena kalian belum dianggap layak jadi siswa SMA, Masa Orientasi Siswa diperpanjang satu hari lagi!”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class=&quot;toy&quot; data-toy=&quot;cry&quot; data-h=&quot;190&quot;&gt;&lt;/div&gt;

&lt;p&gt;Candaan itu masih melekat di ingatan saya, meski hanya sebuah lelucon untuk menambah sensasi.&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;Waktu berjalan sangat cepat. &lt;em&gt;I used to be pretty good at school&lt;/em&gt;. Sebagai siswa SD, mendapatkan nilai tinggi bukanlah masalah besar. Dengarkan guru di kelas, jangan berbuat aneh-aneh, dan sisanya bersenang-senang di rumah dengan PlayStation 2. CD/DVD bajakan? Pasti. Soalnya, memang itu yang ada.&lt;/p&gt;

&lt;div class=&quot;toy&quot; data-toy=&quot;disc&quot; data-h=&quot;180&quot;&gt;&lt;/div&gt;

&lt;p&gt;Saya juga ingat sore-sore santai di rumah. Membaca komik Doraemon sambil meneguk segelas es teh manis — sebenarnya dua gelas, karena keluarga kami memang sangat menyukai teh manis.&lt;/p&gt;

&lt;div class=&quot;toy&quot; data-toy=&quot;esteh&quot; data-h=&quot;190&quot;&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;“Pulang sekolah nanti cukur, ya. Habis itu mampir Gramedia… atau mau beli kaset PS?”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class=&quot;toy&quot; data-toy=&quot;cukur&quot; data-h=&quot;180&quot;&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;p&gt;Waktu masih terasa cepat. Sabtu sore kemarin, kami sekeluarga mampir ke kafe bertema makanan Jepang. Saya hanya memesan &lt;em&gt;ocha&lt;/em&gt; tawar. Bukan karena sedang berdiet atau apa, tapi mungkin karena sekarang saya lebih sadar pentingnya mengurangi gula. Atau mungkin… es teh manis sudah tidak seenak dulu lagi.&lt;/p&gt;

&lt;div class=&quot;toy&quot; data-toy=&quot;ocha&quot; data-h=&quot;190&quot;&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;berjalan&quot;&gt;…berjalan…&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;During college, I used to write blogs—until everything was wiped out, burnt to ashes by my cloud provider due to an issue on their end. In return, I was compensated with a measly one year of free hosting. The rest of my posts? Irrecoverable. Led me to explore GitHub Pages and ultimately, this blog was born.&lt;/em&gt;&lt;/p&gt;

&lt;div class=&quot;toy&quot; data-toy=&quot;fire&quot; data-h=&quot;200&quot;&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Wait, why the heck am I speaking in an alien language again?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Saya sebenarnya sangat mencintai bahasa Indonesia. Meskipun sudah tinggal di Singapura lebih dari tiga tahun, saya masih menikmati Netflix dengan &lt;em&gt;subtitle&lt;/em&gt; bahasa Indonesia, berbicara tanpa campur-campur bahasa Inggris (walau orang bilang itu logat Jaksel), dan berusaha menggunakan Ejaan yang Disempurnakan.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;Tapi kalau ngobrol santai? Ya kali mikirin semua aturan itu, jir.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class=&quot;toy-swap&quot; id=&quot;toy-swap&quot;&gt;&lt;/div&gt;

&lt;hr /&gt;

&lt;p&gt;Saya ingat salah satu tulisan saya di blog lama, “Jujur,” yang diterbitkan 18 Desember 2018. Isinya? Cerita tentang sleep paralysis (ketindihan) dicampur lawakan &lt;em&gt;edgy&lt;/em&gt; khas anak kuliah. Baru-baru ini saya membacanya lagi, dan meskipun &lt;em&gt;cringe&lt;/em&gt;, ada bagian dari diri saya yang terhibur.&lt;/p&gt;

&lt;div class=&quot;toy&quot; data-toy=&quot;tindih&quot; data-h=&quot;200&quot;&gt;&lt;/div&gt;

&lt;p&gt;Kalau penasaran, bisa dilihat di sini: &lt;a href=&quot;/assets/html/Jujur%20-%20Catatan%20Ashura.html&quot;&gt;Jujur - Catatan Ashura&lt;/a&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;terlalu-cepat-sorry-but-can-you-stop-for-a-bit&quot;&gt;…terlalu cepat. &lt;em&gt;Sorry, but, can you stop for a bit?&lt;/em&gt;&lt;/h3&gt;

&lt;p&gt;Kemarin, saya baru pulang dari Indonesia. Akhir Desember memang sudah menjadi tradisi keluarga kami untuk berkumpul di Purwokerto. Mungkin suasana itu membuat saya jadi sentimental, mendorong untuk menulis blog ini dua jam yang lalu. Kini, 2025 sudah tiba. Tahun ini, saya akan berusia 26 tahun. Masih ada mimpi-mimpi yang belum tercapai, sebagian karena saya tidak benar-benar mengejarnya.&lt;/p&gt;

&lt;div class=&quot;toy&quot; data-toy=&quot;jam&quot; data-h=&quot;210&quot;&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;em&gt;I used to be pretty good; now, I’m just your average next-door chill guy.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr /&gt;

&lt;p&gt;Saya menyukai bahasa Indonesia.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Apalagi kalo bisa misuh ga jelas kyk gini. Kenapa udah tahun 2025 aja dah? Cepet bgt cok!&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Semoga harimu menyenangkan.&lt;/p&gt;

&lt;p&gt;Asif, &lt;em&gt;signing off&lt;/em&gt;. 😊&lt;/p&gt;

&lt;div class=&quot;toy&quot; data-toy=&quot;dadah&quot; data-h=&quot;190&quot;&gt;&lt;/div&gt;

&lt;script&gt;
(function(){
  var INK = &apos;#222&apos;, PAPER = &apos;#fbfbf7&apos;, MUTED = &apos;#8a8a80&apos;;

  function ink(ctx, w){ ctx.strokeStyle=INK; ctx.fillStyle=INK; ctx.lineWidth=w||2.2; ctx.lineJoin=&apos;round&apos;; ctx.lineCap=&apos;round&apos;; }
  function txt(ctx, s, x, y, size, color, bold){
    ctx.save();
    ctx.textAlign=&apos;center&apos;;
    ctx.fillStyle = color || MUTED;
    ctx.font = (bold?&apos;bold &apos;:&apos;&apos;) + (size||13) + &apos;px sans-serif&apos;;
    ctx.fillText(s, x, y);
    ctx.restore();
  }
  function rnd(a,b){ return a + Math.random()*(b-a); }

  // ---------------------------------------------------------------- toys ----
  var TOYS = {};

  // 1. MOS diperpanjang: a stickman who cries harder every click
  TOYS.cry = {
    init: function(){ return { lvl:0, tears:[], shake:0, t:0 }; },
    click: function(s){ if (s.lvl &lt; 6) s.lvl++; s.shake = 12; },
    tick: function(s, W, H){
      s.t++; if (s.shake&gt;0) s.shake--;
      var rate = 10 - s.lvl;
      if (s.t % Math.max(2, rate) === 0){
        for (var i=0;i&lt;1+s.lvl;i++){
          var side = Math.random()&lt;0.5 ? -1 : 1;
          s.tears.push({ x: W/2 + side*6 + rnd(-1,1), y: H-96, vy: rnd(1.4,2.6)+s.lvl*0.2, vx: side*rnd(0.1,0.5) });
        }
      }
      var ground = H-30;
      for (var k=s.tears.length-1;k&gt;=0;k--){
        var d = s.tears[k]; d.x+=d.vx; d.y+=d.vy; d.vy+=0.12;
        if (d.y &gt; ground) s.tears.splice(k,1);
      }
    },
    draw: function(ctx, W, H, s){
      var cx = W/2, ground = H-30;
      var head = H-100;
      ctx.save();
      if (s.shake&gt;0) ctx.translate(rnd(-1,1)*s.shake*0.25, rnd(-1,1)*s.shake*0.25);
      ink(ctx);
      // puddle
      if (s.lvl&gt;0){
        ctx.beginPath();
        ctx.ellipse(cx, ground+4, 18+s.lvl*13, 4+s.lvl*0.9, 0, 0, 7);
        ctx.stroke();
      }
      // body
      ctx.beginPath();
      ctx.moveTo(cx, head+15); ctx.lineTo(cx, ground-26);
      ctx.moveTo(cx, ground-26); ctx.lineTo(cx-12, ground);
      ctx.moveTo(cx, ground-26); ctx.lineTo(cx+12, ground);
      // arms up over the face as it gets worse
      var lift = s.lvl*2.2;
      ctx.moveTo(cx, head+26); ctx.lineTo(cx-16, head+30-lift);
      ctx.moveTo(cx, head+26); ctx.lineTo(cx+16, head+30-lift);
      ctx.stroke();
      // head
      ctx.beginPath(); ctx.arc(cx, head, 15, 0, 7); ctx.stroke();
      // eyes: squeezed shut lines
      ctx.beginPath();
      ctx.moveTo(cx-9, head-3); ctx.lineTo(cx-3, head-3);
      ctx.moveTo(cx+3, head-3); ctx.lineTo(cx+9, head-3);
      ctx.stroke();
      // mouth: an open wail, bigger with level
      var mw = 3 + s.lvl*1.4, mh = 2.5 + s.lvl*1.3;
      ctx.beginPath(); ctx.ellipse(cx, head+7, mw, mh, 0, 0, 7); ctx.stroke();
      // tears
      ctx.lineWidth = 1.8;
      for (var i=0;i&lt;s.tears.length;i++){
        var d = s.tears[i];
        ctx.beginPath(); ctx.moveTo(d.x, d.y); ctx.lineTo(d.x-d.vx, d.y-4); ctx.stroke();
      }
      ctx.restore();
      var days = 3 + s.lvl;
      txt(ctx, &apos;MOS hari ke-&apos; + days, W/2, 22, 14, INK, true);
      var sub = s.lvl===0 ? &apos;katanya cuma tiga hari.&apos;
              : s.lvl&lt;3   ? &apos;diperpanjang satu hari lagi!&apos;
              : s.lvl&lt;6   ? &apos;kepala sekolah bilang ini demi sensasi.&apos;
              :             &apos;sudah, cukup. tolong.&apos;;
      txt(ctx, sub, W/2, 40, 12);
    }
  };

  // 2. CD/DVD bajakan: spins, throws a read error, needs to be blown on
  TOYS.disc = {
    init: function(){ return { ang:0, spd:0.03, err:false, clicks:0, puff:0, blown:0 }; },
    click: function(s){
      if (s.err){ s.err=false; s.puff=24; s.blown++; s.spd=0.34; return; }
      s.clicks++; s.spd = Math.min(0.55, s.spd + 0.11);
      if (s.clicks % 4 === 0) s.err = true;
    },
    tick: function(s){
      if (s.puff&gt;0) s.puff--;
      if (s.err){ s.spd *= 0.86; } else { s.spd = Math.max(0.03, s.spd*0.997); }
      s.ang += s.spd;
    },
    draw: function(ctx, W, H, s){
      var cx = W/2, cy = H/2 + 8, R = Math.min(46, H*0.26);
      ink(ctx);
      // disc
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, 7); ctx.stroke();
      ctx.beginPath(); ctx.arc(cx, cy, R*0.22, 0, 7); ctx.stroke();
      // rotating shine + scratches so the spin is visible
      ctx.save(); ctx.translate(cx, cy); ctx.rotate(s.ang);
      ctx.lineWidth = 1.6;
      for (var i=0;i&lt;3;i++){
        var a = i*2.09;
        ctx.beginPath(); ctx.arc(0, 0, R*0.68, a, a+0.7); ctx.stroke();
      }
      ctx.beginPath(); ctx.moveTo(R*0.3, 0); ctx.lineTo(R*0.9, 0); ctx.stroke();
      ctx.restore();
      // blowing puff
      if (s.puff&gt;0){
        ctx.save(); ctx.globalAlpha = s.puff/24; ctx.lineWidth=1.8;
        for (var p=0;p&lt;4;p++){
          var yy = cy - 18 + p*12;
          ctx.beginPath(); ctx.moveTo(cx-R-30+(24-s.puff), yy); ctx.lineTo(cx-R-8+(24-s.puff), yy); ctx.stroke();
        }
        ctx.restore();
        txt(ctx, &apos;*tiup*&apos;, cx - R - 34, cy - 26, 12, INK, true);
      }
      txt(ctx, &apos;CD/DVD — Rp5.000&apos;, W/2, 22, 13, INK, true);
      if (s.err){
        txt(ctx, &apos;DISC READ ERROR&apos;, W/2, H-16, 14, &apos;#c0392b&apos;, true);
      } else if (s.blown&gt;0){
        txt(ctx, &apos;jalan lagi. ditiup memang solusi resmi.&apos;, W/2, H-16, 12);
      } else {
        txt(ctx, s.clicks===0 ? &apos;klik buat mutar&apos; : &apos;muter…&apos;, W/2, H-16, 12);
      }
    }
  };

  // 3. Es teh manis: sip it, it refills, the sugar count keeps going
  TOYS.esteh = {
    init: function(){ return { lv:1, glass:1, sips:0, wob:0 }; },
    click: function(s){
      s.sips++; s.lv -= 0.24; s.wob = 14;
      if (s.lv &lt;= 0.02){ s.glass++; s.lv = 1; }
    },
    tick: function(s){ if (s.wob&gt;0) s.wob--; },
    draw: function(ctx, W, H, s){
      var cx = W/2, top = H*0.42, bot = H-38, gw = 32;
      ctx.save();
      if (s.wob&gt;0) ctx.translate(Math.sin(s.wob*0.9)*s.wob*0.12, 0);
      ink(ctx);
      // glass: slight taper
      ctx.beginPath();
      ctx.moveTo(cx-gw, top); ctx.lineTo(cx-gw*0.78, bot);
      ctx.lineTo(cx+gw*0.78, bot); ctx.lineTo(cx+gw, top);
      ctx.stroke();
      // tea
      var lvY = bot - (bot-top-6)*Math.max(0, s.lv);
      if (s.lv &gt; 0.02){
        var wAt = gw*0.78 + (gw-gw*0.78)*((bot-lvY)/(bot-top));
        ctx.save();
        ctx.beginPath();
        ctx.moveTo(cx-wAt, lvY); ctx.lineTo(cx-gw*0.78, bot);
        ctx.lineTo(cx+gw*0.78, bot); ctx.lineTo(cx+wAt, lvY);
        ctx.closePath();
        ctx.fillStyle = &apos;rgba(176,116,52,0.42)&apos;; ctx.fill();
        ctx.restore();
        ctx.beginPath(); ctx.moveTo(cx-wAt, lvY); ctx.lineTo(cx+wAt, lvY); ctx.stroke();
        // ice cubes
        if (s.lv &gt; 0.45){
          ctx.lineWidth = 1.7;
          ctx.strokeRect(cx-14, lvY+6, 13, 13);
          ctx.strokeRect(cx+2, lvY+11, 12, 12);
        }
      }
      // straw
      ctx.lineWidth = 2.2;
      ctx.beginPath(); ctx.moveTo(cx+10, top-14); ctx.lineTo(cx-6, bot-8); ctx.stroke();
      ctx.restore();
      // sugar cubes stacked at the side, one per sip
      ink(ctx, 1.6);
      for (var i=0;i&lt;Math.min(s.sips, 14); i++){
        var col = Math.floor(i/7), row = i%7;
        ctx.strokeRect(W-58 - col*13, bot-12 - row*12, 10, 10);
      }
      txt(ctx, &apos;gula&apos;, W-52, bot+14, 10);
      txt(ctx, &apos;Gelas ke-&apos; + s.glass + &apos; • &apos; + s.sips + &apos; tegukan&apos;, W/2, 22, 13, INK, true);
      var note = s.glass&lt;2 ? &apos;sambil baca Doraemon.&apos;
               : s.glass&lt;3 ? &apos;gelas kedua, seperti biasa.&apos;
               : s.glass&lt;5 ? &apos;keluarga kami memang begini.&apos;
               :             &apos;ini kenapa sekarang saya minum ocha.&apos;;
      txt(ctx, note, W/2, H-14, 12);
    }
  };

  // 4. Cukur: hair grows back, clippers take it down
  TOYS.cukur = {
    init: function(){ return { len:20, cut:0, bits:[] }; },
    click: function(s){
      s.len = Math.max(2, s.len - 7); s.cut = 14;
      for (var i=0;i&lt;10;i++) s.bits.push({ x: rnd(-18,18), y: rnd(-6,2), vy: rnd(0.6,1.8), vx: rnd(-0.6,0.6), life: 40 });
    },
    tick: function(s){
      if (s.cut&gt;0) s.cut--;
      if (s.len &lt; 20) s.len += 0.012;
      for (var i=s.bits.length-1;i&gt;=0;i--){
        var b=s.bits[i]; b.x+=b.vx; b.y+=b.vy; b.life--;
        if (b.life&lt;=0) s.bits.splice(i,1);
      }
    },
    draw: function(ctx, W, H, s){
      var cx = W/2 - 6, cy = H/2 + 4, R = 24;
      ink(ctx);
      // head, neck, shoulders
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, 7); ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(cx-5, cy+R-2); ctx.lineTo(cx-5, cy+R+6);
      ctx.moveTo(cx+5, cy+R-2); ctx.lineTo(cx+5, cy+R+6);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(cx-R-22, H-26); ctx.quadraticCurveTo(cx, cy+2, cx+R+22, H-26);
      ctx.stroke();
      // face
      ctx.beginPath();
      ctx.arc(cx-8, cy-2, 1.6, 0, 7); ctx.arc(cx+8, cy-2, 1.6, 0, 7); ctx.fill();
      ctx.beginPath(); ctx.arc(cx, cy+4, 6, 0.2*Math.PI, 0.8*Math.PI); ctx.stroke();
      // hair: strands over the top arc
      ctx.lineWidth = 1.9;
      for (var i=0;i&lt;16;i++){
        var a = -Math.PI + 0.18 + i*(Math.PI-0.36)/15;
        var jitter = 0.75 + Math.abs(Math.sin(i*3.7))*0.5;
        var L = s.len*jitter;
        var x1 = cx+Math.cos(a)*R, y1 = cy+Math.sin(a)*R;
        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.lineTo(x1+Math.cos(a)*L*0.9 + Math.sin(i)*2, y1+Math.sin(a)*L);
        ctx.stroke();
      }
      // clippers, snapping in on a click
      ctx.save();
      ctx.translate(cx + R + 26 - (s.cut&gt;0 ? 14 : 0), cy - 16);
      ink(ctx, 2);
      ctx.strokeRect(-9, -7, 18, 26);
      ctx.beginPath(); ctx.moveTo(-9, -7); ctx.lineTo(9, -7); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-7, -11); ctx.lineTo(7, -11); ctx.stroke();
      ctx.restore();
      // falling hair bits
      ctx.save(); ctx.translate(cx, cy+R);
      ctx.lineWidth = 1.5;
      for (var k=0;k&lt;s.bits.length;k++){
        var b = s.bits[k];
        ctx.globalAlpha = Math.max(0, b.life/40);
        ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.x+2, b.y+3); ctx.stroke();
      }
      ctx.restore();
      txt(ctx, &apos;Pulang sekolah, cukur dulu&apos;, W/2, 22, 13, INK, true);
      var note = s.len&gt;16 ? &apos;sudah kelewat panjang.&apos;
               : s.len&gt;9  ? &apos;nah, rapi.&apos;
               :            &apos;kayak anak baru masuk SMA lagi.&apos;;
      txt(ctx, note, W/2, H-12, 12);
    }
  };

  // 5. Ocha tawar: sip it, no sugar, and it turns out fine
  TOYS.ocha = {
    init: function(){ return { lv:1, sips:0, steam:0 }; },
    click: function(s){ s.sips++; s.lv = Math.max(0, s.lv - 0.28); },
    tick: function(s){ s.steam += 0.05; },
    draw: function(ctx, W, H, s){
      var cx = W/2 - 4, top = 58, bot = H-34, r = 30;
      ink(ctx);
      // cup + handle
      ctx.beginPath();
      ctx.moveTo(cx-r, top); ctx.lineTo(cx-r*0.82, bot); ctx.lineTo(cx+r*0.82, bot); ctx.lineTo(cx+r, top);
      ctx.stroke();
      ctx.beginPath(); ctx.moveTo(cx+r, top+8); ctx.quadraticCurveTo(cx+r+18, top+22, cx+r*0.9, top+36); ctx.stroke();
      // tea
      if (s.lv &gt; 0.02){
        var lvY = bot - (bot-top-6)*s.lv;
        var wAt = r*0.82 + (r-r*0.82)*((bot-lvY)/(bot-top));
        ctx.save();
        ctx.beginPath();
        ctx.moveTo(cx-wAt, lvY); ctx.lineTo(cx-r*0.82, bot); ctx.lineTo(cx+r*0.82, bot); ctx.lineTo(cx+wAt, lvY);
        ctx.closePath();
        ctx.fillStyle = &apos;rgba(124,150,96,0.38)&apos;; ctx.fill();
        ctx.restore();
        ctx.beginPath(); ctx.moveTo(cx-wAt, lvY); ctx.lineTo(cx+wAt, lvY); ctx.stroke();
        // steam
        ctx.lineWidth = 1.7;
        for (var i=0;i&lt;2;i++){
          ctx.beginPath();
          for (var y=0;y&lt;22;y+=4){
            var xx = cx - 8 + i*16 + Math.sin(s.steam + y*0.3 + i)*3;
            if (y===0) ctx.moveTo(xx, top-6-y); else ctx.lineTo(xx, top-6-y);
          }
          ctx.stroke();
        }
      }
      // a small face reacting on the right
      var fx = W-46, fy = H/2 + 6;
      ink(ctx, 2);
      ctx.beginPath(); ctx.arc(fx, fy, 16, 0, 7); ctx.stroke();
      ctx.beginPath(); ctx.arc(fx-6, fy-3, 1.5, 0, 7); ctx.arc(fx+6, fy-3, 1.5, 0, 7); ctx.fill();
      ctx.beginPath();
      if (s.sips === 0){ ctx.moveTo(fx-5, fy+6); ctx.lineTo(fx+5, fy+6); }         // neutral
      else if (s.sips &lt; 3){ ctx.moveTo(fx-5, fy+7); ctx.lineTo(fx+5, fy+5); }      // unimpressed
      else ctx.arc(fx, fy+3, 6, 0.15*Math.PI, 0.85*Math.PI);                        // fine, actually
      ctx.stroke();
      txt(ctx, &apos;Ocha — tanpa gula&apos;, W/2, 22, 13, INK, true);
      var note = s.sips===0 ? &apos;tawar. betulan tawar.&apos;
               : s.sips&lt;3   ? &apos;tidak ada rasa apa-apa di sini.&apos;
               : s.lv&gt;0     ? &apos;ternyata bisa juga terbiasa.&apos;
               :              &apos;habis. es teh manis tetap saya sayang, kok.&apos;;
      txt(ctx, note, W/2, H-14, 12);
    }
  };

  // 6. Blog lama kebakar: put out the fire, the ashes become this blog
  TOYS.fire = {
    init: function(){ return { fire:1, drops:[], ash:[], done:false, t:0 }; },
    click: function(s, W){
      if (s.done) return;
      s.fire -= 0.26;
      for (var i=0;i&lt;7;i++) s.drops.push({ x: rnd(W*0.3, W*0.7), y: rnd(10,30), vy: rnd(2,4) });
      if (s.fire &lt;= 0){
        s.fire = 0; s.done = true;
        for (var k=0;k&lt;26;k++) s.ash.push({ x: rnd(W*0.32,W*0.68), y: rnd(40,110), vy: rnd(-0.9,-0.3), vx: rnd(-0.4,0.4), life: 90 });
      }
    },
    tick: function(s, W, H){
      s.t++;
      for (var i=s.drops.length-1;i&gt;=0;i--){ var d=s.drops[i]; d.y+=d.vy; d.vy+=0.2; if (d.y&gt;H-24) s.drops.splice(i,1); }
      for (var k=s.ash.length-1;k&gt;=0;k--){ var a=s.ash[k]; a.x+=a.vx; a.y+=a.vy; a.life--; if (a.life&lt;=0) s.ash.splice(k,1); }
    },
    draw: function(ctx, W, H, s){
      var cx = W/2, pw = 62, ph = 78, py = H-30;
      ink(ctx);
      if (!s.done){
        // a sheet of posts, burning from the top
        ctx.beginPath();
        ctx.moveTo(cx-pw/2, py); ctx.lineTo(cx-pw/2, py-ph); ctx.lineTo(cx+pw/2-12, py-ph);
        ctx.lineTo(cx+pw/2, py-ph+12); ctx.lineTo(cx+pw/2, py); ctx.closePath();
        ctx.stroke();
        ctx.lineWidth = 1.6;
        for (var i=0;i&lt;5;i++){
          var ly = py-ph+22+i*11;
          ctx.beginPath(); ctx.moveTo(cx-pw/2+9, ly); ctx.lineTo(cx+pw/2-9-((i%2)*14), ly); ctx.stroke();
        }
        // flames
        ctx.strokeStyle = &apos;#e0632a&apos;; ctx.lineWidth = 2.2;
        var f = Math.max(0, s.fire);
        for (var k=0;k&lt;3;k++){
          var fx = cx - 22 + k*22, hgt = (26 + Math.sin(s.t*0.2 + k)*5) * f;
          ctx.beginPath();
          ctx.moveTo(fx-9, py-ph+2);
          ctx.quadraticCurveTo(fx-4, py-ph-hgt*0.6, fx, py-ph-hgt);
          ctx.quadraticCurveTo(fx+5, py-ph-hgt*0.5, fx+9, py-ph+2);
          ctx.stroke();
        }
      } else {
        // rebuilt: a small browser window
        ink(ctx);
        ctx.strokeRect(cx-72, py-72, 144, 72);
        ctx.beginPath(); ctx.moveTo(cx-72, py-56); ctx.lineTo(cx+72, py-56); ctx.stroke();
        ctx.beginPath();
        ctx.arc(cx-62, py-64, 2.6, 0, 7); ctx.arc(cx-52, py-64, 2.6, 0, 7); ctx.arc(cx-42, py-64, 2.6, 0, 7);
        ctx.stroke();
        ctx.lineWidth = 1.6;
        ctx.beginPath();
        ctx.moveTo(cx-60, py-42); ctx.lineTo(cx+40, py-42);
        ctx.moveTo(cx-60, py-30); ctx.lineTo(cx+16, py-30);
        ctx.moveTo(cx-60, py-18); ctx.lineTo(cx+52, py-18);
        ctx.stroke();
        txt(ctx, &apos;ashura.id&apos;, cx + 22, py-59, 10, INK, true);
      }
      // water drops
      ctx.strokeStyle = &apos;#3b7ea1&apos;; ctx.lineWidth = 2;
      for (var d=0;d&lt;s.drops.length;d++){
        var dr = s.drops[d];
        ctx.beginPath(); ctx.moveTo(dr.x, dr.y); ctx.lineTo(dr.x, dr.y+5); ctx.stroke();
      }
      // rising ash
      ctx.fillStyle = &apos;#999&apos;;
      for (var a=0;a&lt;s.ash.length;a++){
        var as = s.ash[a];
        ctx.globalAlpha = Math.max(0, as.life/90);
        ctx.fillRect(as.x, as.y, 2, 2);
      }
      ctx.globalAlpha = 1;
      txt(ctx, s.done ? &apos;Blog ini&apos; : &apos;Blog lama, 2018&apos;, W/2, 22, 13, INK, true);
      var note = s.done ? &apos;abunya jadi halaman yang sedang kamu baca.&apos;
               : s.fire&gt;0.5 ? &apos;gantinya: hosting gratis satu tahun.&apos;
               : &apos;hampir padam. tulisannya tetap hilang.&apos;;
      txt(ctx, note, W/2, H-12, 12);
    }
  };

  // 7. Ketindihan: the shadow leans closer — until it turns out to be a cat
  TOYS.tindih = {
    init: function(){ return { lvl:0, t:0 }; },
    click: function(s){ if (s.lvl&lt;5) s.lvl++; },
    tick: function(s){ s.t++; },
    draw: function(ctx, W, H, s){
      var bx = W*0.5, by = H-42;
      ink(ctx);
      // bed
      ctx.beginPath();
      ctx.moveTo(bx-86, by); ctx.lineTo(bx+86, by);
      ctx.moveTo(bx-86, by); ctx.lineTo(bx-86, by+16);
      ctx.moveTo(bx+86, by); ctx.lineTo(bx+86, by+16);
      ctx.stroke();
      // blanket + head on pillow
      ctx.beginPath();
      ctx.moveTo(bx-58, by); ctx.quadraticCurveTo(bx-20, by-26, bx+34, by-14); ctx.lineTo(bx+70, by-12);
      ctx.stroke();
      ctx.beginPath(); ctx.arc(bx-70, by-14, 13, 0, 7); ctx.stroke();
      // wide-awake eyes
      ctx.beginPath();
      ctx.arc(bx-74, by-17, 2.2, 0, 7); ctx.arc(bx-65, by-17, 2.2, 0, 7); ctx.fill();
      ctx.beginPath(); ctx.moveTo(bx-74, by-8); ctx.lineTo(bx-66, by-8); ctx.stroke();

      var lean = s.lvl;
      if (s.lvl &lt; 5){
        // a shadow at the foot of the bed, leaning in
        var sh = 34 + lean*13, sx = bx + 54 - lean*10, sy = by - 10;
        ctx.save();
        ctx.fillStyle = &apos;rgba(34,34,34,&apos; + (0.45 + lean*0.12) + &apos;)&apos;;
        ctx.beginPath();
        ctx.moveTo(sx-16, sy);
        ctx.quadraticCurveTo(sx-14, sy-sh, sx, sy-sh-6);
        ctx.quadraticCurveTo(sx+14, sy-sh, sx+16, sy);
        ctx.closePath(); ctx.fill();
        // two pale eyes
        ctx.fillStyle = PAPER;
        ctx.beginPath();
        ctx.arc(sx-5, sy-sh+6, 2.4, 0, 7); ctx.arc(sx+5, sy-sh+6, 2.4, 0, 7); ctx.fill();
        ctx.restore();
      } else {
        // it was a cat sitting on your chest the whole time
        var kx = bx + 6, ky = by - 16;
        ink(ctx);
        ctx.beginPath(); ctx.ellipse(kx, ky-6, 19, 12, 0, 0, 7); ctx.stroke();
        ctx.beginPath(); ctx.arc(kx+16, ky-18, 11, 0, 7); ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(kx+7, ky-24); ctx.lineTo(kx+9, ky-34); ctx.lineTo(kx+17, ky-27);
        ctx.moveTo(kx+19, ky-28); ctx.lineTo(kx+26, ky-34); ctx.lineTo(kx+26, ky-23);
        ctx.stroke();
        ctx.beginPath();
        ctx.arc(kx+13, ky-19, 1.4, 0, 7); ctx.arc(kx+20, ky-19, 1.4, 0, 7); ctx.fill();
        ctx.beginPath();
        ctx.moveTo(kx-18, ky-4); ctx.quadraticCurveTo(kx-34, ky-2, kx-30, ky-18);
        ctx.stroke();
      }
      txt(ctx, s.lvl&lt;5 ? &apos;Jam 03.00&apos; : &apos;Jam 03.00&apos;, W/2, 22, 13, INK, true);
      var note = s.lvl===0 ? &apos;badan tidak mau bergerak.&apos;
               : s.lvl&lt;3   ? &apos;ketindihan: &apos; + (s.lvl*20) + &apos;%&apos;
               : s.lvl&lt;5   ? &apos;makin dekat. mata tidak bisa dipejamkan.&apos;
               :             &apos;ternyata cuma kucing. selalu cuma kucing.&apos;;
      txt(ctx, note, W/2, H-12, 12);
    }
  };

  // 8. Waktu: try to stop the clock; you can&apos;t, but it does calm down
  TOYS.jam = {
    init: function(){ return { ang:0, spd:0.16, clicks:0, calm:false }; },
    click: function(s){
      s.clicks++;
      s.spd *= 0.22;
      if (s.clicks &gt;= 3) s.calm = true;
    },
    tick: function(s){
      if (!s.calm) s.spd += (0.16 - s.spd)*0.02; else s.spd += (0.02 - s.spd)*0.04;
      s.ang += s.spd;
    },
    draw: function(ctx, W, H, s){
      var cx = W/2, cy = H/2 + 6, R = Math.min(52, H*0.28);
      ink(ctx);
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, 7); ctx.stroke();
      ctx.lineWidth = 1.7;
      for (var i=0;i&lt;12;i++){
        var a = i*Math.PI/6;
        ctx.beginPath();
        ctx.moveTo(cx+Math.cos(a)*(R-4), cy+Math.sin(a)*(R-4));
        ctx.lineTo(cx+Math.cos(a)*(R-9), cy+Math.sin(a)*(R-9));
        ctx.stroke();
      }
      ink(ctx, 2.6);
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.lineTo(cx+Math.cos(s.ang-Math.PI/2)*(R-14), cy+Math.sin(s.ang-Math.PI/2)*(R-14));
      ctx.stroke();
      ink(ctx, 3);
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.lineTo(cx+Math.cos(s.ang/12-Math.PI/2)*(R-26), cy+Math.sin(s.ang/12-Math.PI/2)*(R-26));
      ctx.stroke();
      ctx.beginPath(); ctx.arc(cx, cy, 3, 0, 7); ctx.fill();
      var year = 2025 + Math.floor(s.ang/(Math.PI*2*12));
      txt(ctx, String(year), W/2, 24, 15, INK, true);
      var note = s.calm ? &apos;tidak bisa distop. tapi kamu boleh duduk dulu.&apos;
               : s.clicks===0 ? &apos;klik kalau mau coba menahannya.&apos;
               : s.clicks===1 ? &apos;melambat sebentar…&apos;
               : &apos;lalu jalan lagi, seperti biasa.&apos;;
      txt(ctx, note, W/2, H-14, 12);
    }
  };

  // 9. Penutup: a stickman who waves back
  TOYS.dadah = {
    init: function(){ return { e:0.15, t:0, hearts:[] }; },
    click: function(s, W, H){
      s.e = Math.min(1, s.e + 0.42);
      for (var i=0;i&lt;5;i++) s.hearts.push({ x: W/2 + rnd(-26,26), y: H-90, vy: rnd(-1.6,-0.7), life: 60 });
    },
    tick: function(s){
      s.t++; s.e = Math.max(0.15, s.e - 0.0035);
      for (var i=s.hearts.length-1;i&gt;=0;i--){ var h=s.hearts[i]; h.y+=h.vy; h.life--; if (h.life&lt;=0) s.hearts.splice(i,1); }
    },
    draw: function(ctx, W, H, s){
      var cx = W/2, ground = H-30, head = H-104;
      ink(ctx);
      var bob = Math.sin(s.t*0.1*(0.5+s.e))*s.e*2;
      ctx.save(); ctx.translate(0, bob);
      ctx.beginPath();
      ctx.moveTo(cx, head+15); ctx.lineTo(cx, ground-26);
      ctx.moveTo(cx, ground-26); ctx.lineTo(cx-12, ground);
      ctx.moveTo(cx, ground-26); ctx.lineTo(cx+12, ground);
      ctx.moveTo(cx, head+26); ctx.lineTo(cx-17, head+34);
      ctx.stroke();
      // waving arm: shoulder → elbow → hand, up above the head
      var wav = Math.sin(s.t*0.22*(0.6+s.e*1.6)) * (0.3 + s.e*0.7);
      var ex = cx + 16, ey = head + 12;
      ctx.beginPath();
      ctx.moveTo(cx, head+26);
      ctx.lineTo(ex, ey);
      ctx.lineTo(ex + 8 + wav*10, ey - 24 - Math.abs(wav)*4);
      ctx.stroke();
      // open palm
      ctx.beginPath();
      ctx.arc(ex + 8 + wav*10, ey - 26 - Math.abs(wav)*4, 3.4, 0, 7);
      ctx.stroke();
      // head + smile
      ctx.beginPath(); ctx.arc(cx, head, 15, 0, 7); ctx.stroke();
      ctx.beginPath();
      ctx.arc(cx-5, head-4, 1.7, 0, 7); ctx.arc(cx+5, head-4, 1.7, 0, 7); ctx.fill();
      ctx.beginPath(); ctx.arc(cx, head+1, 8, 0.15*Math.PI, 0.85*Math.PI); ctx.stroke();
      ctx.restore();
      // hearts
      ctx.save(); ctx.textAlign=&apos;center&apos;; ctx.fillStyle=&apos;#c0392b&apos;;
      for (var i=0;i&lt;s.hearts.length;i++){
        var h = s.hearts[i];
        ctx.globalAlpha = Math.max(0, h.life/60);
        ctx.font = &apos;bold 13px sans-serif&apos;;
        ctx.fillText(&apos;♥&apos;, h.x, h.y);
      }
      ctx.restore();
      txt(ctx, &apos;Semoga harimu menyenangkan&apos;, W/2, 24, 13, INK, true);
      txt(ctx, s.e&gt;0.75 ? &apos;makasih sudah baca sampai sini.&apos; : &apos;dia bakal balas kalau kamu duluan.&apos;, W/2, H-14, 12);
    }
  };

  // ------------------------------------------------------------ bootstrap ----
  var mounted = [];
  var nodes = document.querySelectorAll(&apos;.toy[data-toy]&apos;);
  for (var i=0;i&lt;nodes.length;i++){
    (function(el, first){
      var def = TOYS[el.getAttribute(&apos;data-toy&apos;)];
      if (!def) return;
      var h = parseInt(el.getAttribute(&apos;data-h&apos;) || &apos;180&apos;, 10);
      var cv = document.createElement(&apos;canvas&apos;);
      el.appendChild(cv);
      // only the first one says anything; after that you already know
      if (first){
        var hint = document.createElement(&apos;div&apos;);
        hint.className = &apos;hint&apos;;
        hint.textContent = &apos;Tip: doodle di tulisan ini bisa diklik.&apos;;
        el.appendChild(hint);
      }
      var toy = { el:el, cv:cv, ctx:cv.getContext(&apos;2d&apos;), def:def, h:h, s:def.init() };
      function size(){
        var w = Math.max(240, Math.min(460, el.clientWidth || 460));
        cv.width = Math.round(w); cv.height = h;
      }
      toy.size = size; size();
      function hit(e){
        if (e &amp;&amp; e.preventDefault) e.preventDefault();
        el.classList.add(&apos;touched&apos;);
        def.click &amp;&amp; def.click(toy.s, cv.width, cv.height);
      }
      cv.addEventListener(&apos;mousedown&apos;, hit);
      // On touch, wait for the finger to lift: a tap plays, a swipe scrolls past.
      // Nothing here calls preventDefault until we know it was a tap, so the
      // page keeps scrolling even when your thumb starts on a doodle.
      var sx=0, sy=0, st=0, slid=false;
      cv.addEventListener(&apos;touchstart&apos;, function(e){
        var p = e.touches[0]; if (!p) return;
        sx = p.clientX; sy = p.clientY; st = Date.now(); slid = false;
      }, {passive:true});
      cv.addEventListener(&apos;touchmove&apos;, function(e){
        var p = e.touches[0]; if (!p) return;
        if (Math.abs(p.clientX-sx) &gt; 10 || Math.abs(p.clientY-sy) &gt; 10) slid = true;
      }, {passive:true});
      cv.addEventListener(&apos;touchend&apos;, function(e){
        if (slid || Date.now()-st &gt; 600) return;   // that was a scroll, not a tap
        e.preventDefault();                        // also cancels the synthetic click
        hit();
      });
      mounted.push(toy);
    })(nodes[i], i === 0);
  }

  window.addEventListener(&apos;resize&apos;, function(){
    for (var i=0;i&lt;mounted.length;i++) mounted[i].size();
  });

  function loop(){
    var vh = window.innerHeight || 800;
    for (var i=0;i&lt;mounted.length;i++){
      var toy = mounted[i];
      var r = toy.cv.getBoundingClientRect();
      if (r.bottom &lt; -80 || r.top &gt; vh + 80) continue; // offscreen: don&apos;t burn cycles
      var W = toy.cv.width, H = toy.cv.height;
      toy.def.tick &amp;&amp; toy.def.tick(toy.s, W, H);
      toy.ctx.fillStyle = PAPER; toy.ctx.fillRect(0,0,W,H);
      toy.def.draw(toy.ctx, W, H, toy.s);
    }
    requestAnimationFrame(loop);
  }
  loop();

  // ---- EYD ↔ Jaksel toggle (text, not canvas) ----
  var swap = document.getElementById(&apos;toy-swap&apos;);
  if (swap){
    var modes = [
      { tag:&apos;EYD&apos;, text:&apos;Sesungguhnya saya harus pulang lebih awal hari ini karena ada urusan keluarga yang tidak dapat ditunda.&apos; },
      { tag:&apos;Jaksel&apos;, text:&apos;Which is, gue harus cabut duluan, ada family thing yang literally nggak bisa di-postpone, sih.&apos; }
    ];
    var m = 0;
    var tag = document.createElement(&apos;div&apos;); tag.className = &apos;tag&apos;;
    var sen = document.createElement(&apos;div&apos;); sen.className = &apos;sentence&apos;;
    swap.appendChild(tag); swap.appendChild(sen);
    function render(){ tag.textContent = modes[m].tag; sen.textContent = modes[m].text; }
    render();
    swap.addEventListener(&apos;click&apos;, function(){ m = (m+1) % modes.length; render(); });
  }
})();
&lt;/script&gt;

</content>
 </entry>
 

</feed>
