Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

carry: base-10 arithmetic piece — tap columns, 10 beads collapse upward

Visual place-value game. Three columns (1s/10s/100s), tap to add beads,
ten in a column chain-carries into one bead in the next, target matching
scores. Also registered in docs.js so it shows up in list/autocomplete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+369
+5
system/netlify/functions/docs.js
··· 3703 3703 examples: ["camera", "camera:under"], 3704 3704 done: true, 3705 3705 }, 3706 + carry: { 3707 + sig: "carry", 3708 + desc: "Learn base 10 by feel — tap columns, watch ten become one.", 3709 + done: false, 3710 + }, 3706 3711 chat: { 3707 3712 sig: "chat", 3708 3713 desc: "Chat with handles.",
+364
system/public/aesthetic.computer/disks/carry.mjs
··· 1 + // Carry, 26.04.19.00.00 2 + // Learn base-10 arithmetic by feel. Tap columns (1s, 10s, 100s) to add 3 + // beads. Ten beads in a column collapse into one bead in the next column — 4 + // that is place value made physical. Hit the target number exactly to score. 5 + 6 + const { floor, min, max, sin, PI, random } = Math; 7 + 8 + const CAP = 10; 9 + const VALUE = [1, 10, 100]; 10 + const LABELS = ["1", "10", "100"]; 11 + const COLUMN_COLORS = [ 12 + [80, 200, 255], 13 + [255, 180, 60], 14 + [230, 100, 220], 15 + ]; 16 + 17 + let target = 0; 18 + let fillLevel = [0, 0, 0]; 19 + let flash = [0, 0, 0]; 20 + let carry = null; 21 + let won = 0; 22 + let overshot = 0; 23 + let score = 0; 24 + let best = 0; 25 + let frame = 0; 26 + 27 + let columnBtns = [null, null, null]; 28 + let columnBoxes = [null, null, null]; 29 + let resetBtn = null; 30 + 31 + function total() { 32 + return fillLevel[0] + fillLevel[1] * 10 + fillLevel[2] * 100; 33 + } 34 + 35 + function pickTarget(num) { 36 + const ceiling = min(999, 10 + score * 11); 37 + target = num.randIntRange(1, ceiling); 38 + fillLevel = [0, 0, 0]; 39 + carry = null; 40 + won = 0; 41 + overshot = 0; 42 + } 43 + 44 + function layout(screen) { 45 + const topZone = 72; 46 + const bottomZone = 28; 47 + const gap = 6; 48 + const colW = floor((screen.width - gap * 4) / 3); 49 + const colH = screen.height - topZone - bottomZone; 50 + for (let i = 0; i < 3; i++) { 51 + const visIdx = 2 - i; 52 + const x = gap + visIdx * (colW + gap); 53 + columnBoxes[i] = { x, y: topZone, w: colW, h: colH }; 54 + } 55 + } 56 + 57 + function setupButtons(ui) { 58 + for (let i = 0; i < 3; i++) { 59 + const b = columnBoxes[i]; 60 + columnBtns[i] = new ui.Button(b.x, b.y, b.w, b.h); 61 + } 62 + resetBtn = new ui.Button(0, 0, 40, 16); 63 + } 64 + 65 + function boot({ screen, ui, num, hud }) { 66 + hud.labelBack?.(); 67 + layout(screen); 68 + setupButtons(ui); 69 + pickTarget(num); 70 + } 71 + 72 + function addBead(col, sound) { 73 + if (carry || won > 0 || overshot > 0) return; 74 + if (col === 2 && fillLevel[2] >= CAP - 1) { 75 + triggerOvershoot(sound); 76 + return; 77 + } 78 + fillLevel[col] += 1; 79 + flash[col] = 1; 80 + sound?.synth({ 81 + type: "sine", 82 + tone: 420 + col * 140, 83 + duration: 0.05, 84 + volume: 0.35, 85 + }); 86 + if (fillLevel[col] >= CAP) { 87 + carry = { col, t: 0 }; 88 + } else { 89 + checkState(sound); 90 + } 91 + } 92 + 93 + function checkState(sound) { 94 + const t = total(); 95 + if (t === target) { 96 + won = 70; 97 + score += 1; 98 + best = max(best, score); 99 + sound?.synth({ type: "sine", tone: 523, duration: 0.25, volume: 0.3 }); 100 + sound?.synth({ type: "sine", tone: 659, duration: 0.25, volume: 0.3 }); 101 + sound?.synth({ type: "sine", tone: 784, duration: 0.35, volume: 0.3 }); 102 + } else if (t > target) { 103 + triggerOvershoot(sound); 104 + } 105 + } 106 + 107 + function triggerOvershoot(sound) { 108 + overshot = 45; 109 + score = 0; 110 + sound?.synth({ 111 + type: "sawtooth", 112 + tone: 110, 113 + duration: 0.35, 114 + volume: 0.22, 115 + }); 116 + } 117 + 118 + function sim({ sound, num }) { 119 + frame += 1; 120 + if (won > 0) { 121 + won -= 1; 122 + if (won === 0) pickTarget(num); 123 + } 124 + if (overshot > 0) { 125 + overshot -= 1; 126 + if (overshot === 0) { 127 + fillLevel = [0, 0, 0]; 128 + carry = null; 129 + } 130 + } 131 + for (let i = 0; i < 3; i++) { 132 + if (flash[i] > 0) flash[i] *= 0.86; 133 + } 134 + if (carry) { 135 + carry.t += 1; 136 + if (carry.t >= 45) { 137 + const c = carry.col; 138 + fillLevel[c] = 0; 139 + if (c < 2) { 140 + fillLevel[c + 1] += 1; 141 + flash[c + 1] = 1; 142 + const note = [0, 659, 784][c + 1] || 784; 143 + sound?.synth({ 144 + type: "triangle", 145 + tone: note, 146 + duration: 0.22, 147 + volume: 0.4, 148 + }); 149 + } else { 150 + sound?.synth({ 151 + type: "sawtooth", 152 + tone: 90, 153 + duration: 0.4, 154 + volume: 0.2, 155 + }); 156 + } 157 + carry = null; 158 + if (fillLevel[2] >= CAP) { 159 + fillLevel[2] = CAP - 1; 160 + triggerOvershoot(sound); 161 + } else if (c < 2 && fillLevel[c + 1] >= CAP) { 162 + carry = { col: c + 1, t: 0 }; 163 + } else { 164 + checkState(sound); 165 + } 166 + } 167 + } 168 + } 169 + 170 + function drawColumn($, i, shakeX, shakeY) { 171 + const { ink } = $; 172 + const b = columnBoxes[i]; 173 + const color = COLUMN_COLORS[i]; 174 + const isActive = columnBtns[i]?.down; 175 + const isDark = $.dark; 176 + 177 + const bg = isDark 178 + ? [floor(color[0] * 0.12), floor(color[1] * 0.12), floor(color[2] * 0.12)] 179 + : [ 180 + floor(255 - (255 - color[0]) * 0.15), 181 + floor(255 - (255 - color[1]) * 0.15), 182 + floor(255 - (255 - color[2]) * 0.15), 183 + ]; 184 + ink(...bg).box(b.x + shakeX, b.y + shakeY, b.w, b.h, "fill"); 185 + 186 + const outlineBoost = isActive ? 80 : 0; 187 + ink( 188 + min(255, color[0] + outlineBoost), 189 + min(255, color[1] + outlineBoost), 190 + min(255, color[2] + outlineBoost), 191 + ).box(b.x + shakeX, b.y + shakeY, b.w, b.h, "outline"); 192 + 193 + ink(color[0], color[1], color[2]).write(LABELS[i], { 194 + x: b.x + shakeX + 6, 195 + y: b.y + shakeY + 6, 196 + size: 2, 197 + }); 198 + 199 + const count = fillLevel[i]; 200 + const beadSlot = floor((b.h - 28) / CAP); 201 + const beadSize = min(beadSlot - 2, b.w - 16); 202 + const beadX = b.x + shakeX + floor((b.w - beadSize) / 2); 203 + 204 + const isCarrying = carry && carry.col === i; 205 + const carryProg = isCarrying ? carry.t / 45 : 0; 206 + 207 + for (let k = 0; k < count; k++) { 208 + let bx = beadX; 209 + let by = b.y + shakeY + b.h - 8 - (k + 1) * beadSlot; 210 + let alpha = 255; 211 + let pulse = 1; 212 + 213 + if (isCarrying) { 214 + if (carryProg < 0.5) { 215 + pulse = 1 + sin(carryProg * PI * 6 + k * 0.3) * 0.18; 216 + } else { 217 + const p = (carryProg - 0.5) / 0.5; 218 + if (i < 2) { 219 + const nextB = columnBoxes[i + 1]; 220 + const nextCount = fillLevel[i + 1]; 221 + const nextY = nextB.y + nextB.h - 8 - (nextCount + 1) * beadSlot; 222 + const targetX = 223 + nextB.x + shakeX + floor((nextB.w - beadSize) / 2); 224 + bx = floor(bx + (targetX - bx) * p); 225 + by = floor(by + (nextY - by) * p); 226 + } else { 227 + by = floor(by - p * 30); 228 + alpha = floor((1 - p) * 255); 229 + } 230 + pulse = 1 - p * 0.55; 231 + } 232 + } 233 + 234 + const size = max(2, floor(beadSize * pulse)); 235 + const cx = bx + floor(beadSize / 2); 236 + const cy = by + floor(beadSlot / 2); 237 + ink(color[0], color[1], color[2], alpha).box( 238 + cx - floor(size / 2), 239 + cy - floor(size / 2), 240 + size, 241 + size, 242 + "fill", 243 + ); 244 + ink(255, 255, 255, floor(alpha * 0.45)).box( 245 + cx - floor(size / 2), 246 + cy - floor(size / 2), 247 + size, 248 + size, 249 + "outline", 250 + ); 251 + } 252 + 253 + if (flash[i] > 0.03) { 254 + ink(255, 255, 255, floor(flash[i] * 80)).box( 255 + b.x + shakeX, 256 + b.y + shakeY, 257 + b.w, 258 + b.h, 259 + "fill", 260 + ); 261 + } 262 + } 263 + 264 + function paint($) { 265 + const { wipe, ink, screen } = $; 266 + const isDark = $.dark; 267 + const bg = isDark ? [8, 12, 20] : [242, 240, 250]; 268 + wipe(...bg); 269 + 270 + let sx = 0; 271 + let sy = 0; 272 + if (overshot > 0) { 273 + sx = floor((random() - 0.5) * 6); 274 + sy = floor((random() - 0.5) * 6); 275 + } 276 + 277 + const cur = total(); 278 + const tgtColor = 279 + cur === target 280 + ? [60, 220, 90] 281 + : cur > target 282 + ? [240, 80, 80] 283 + : isDark 284 + ? [235, 235, 245] 285 + : [30, 30, 50]; 286 + ink(...tgtColor).write(`${target}`, { 287 + center: "x", 288 + y: 10, 289 + size: 4, 290 + screen, 291 + }); 292 + 293 + const curColor = 294 + cur === target 295 + ? [60, 220, 90] 296 + : cur > target 297 + ? [240, 80, 80] 298 + : isDark 299 + ? [140, 140, 170] 300 + : [140, 140, 170]; 301 + ink(...curColor).write(`= ${cur}`, { 302 + center: "x", 303 + y: 44, 304 + size: 2, 305 + screen, 306 + }); 307 + 308 + for (let i = 2; i >= 0; i--) drawColumn($, i, sx, sy); 309 + 310 + const footColor = isDark ? [160, 160, 180] : [90, 90, 110]; 311 + ink(...footColor).write(`score ${score} best ${best}`, { 312 + x: 4, 313 + y: screen.height - 14, 314 + }); 315 + ink(...footColor).write(`tap a column`, { 316 + x: screen.width - 72, 317 + y: screen.height - 14, 318 + }); 319 + 320 + if (won > 0) { 321 + const a = floor((won / 70) * 230); 322 + ink(60, 220, 90, a).box( 323 + 0, 324 + floor(screen.height / 2) - 18, 325 + screen.width, 326 + 36, 327 + ); 328 + ink(10, 40, 20, a).write("NICE!", { 329 + center: "xy", 330 + size: 3, 331 + screen, 332 + }); 333 + } 334 + 335 + if (overshot > 0 && overshot > 20) { 336 + const a = floor((overshot / 45) * 180); 337 + ink(240, 80, 80, a).write("OVER", { 338 + center: "xy", 339 + size: 3, 340 + screen, 341 + }); 342 + } 343 + } 344 + 345 + function act({ event: e, screen, ui, sound }) { 346 + if (e.is("reframed")) { 347 + layout(screen); 348 + setupButtons(ui); 349 + } 350 + for (let i = 0; i < 3; i++) { 351 + columnBtns[i]?.act(e, { 352 + push: () => addBead(i, sound), 353 + }); 354 + } 355 + } 356 + 357 + function meta() { 358 + return { 359 + title: "Carry", 360 + desc: "Learn base 10 by feel — tap columns, watch ten become one.", 361 + }; 362 + } 363 + 364 + export { boot, sim, paint, act, meta };