Amidakuji
Click between two verticals to lay a rung. Click a letter at the top and a ball rolls down, taking every rung it meets.
What is a drawing and what is the application talking
Four things move on this page, and only two of them are in the document.
The ladder is a drawing. Verticals and rungs are line shapes. Press Save PNG and they come out, because they are what the page is of.
The trail is a drawing too. It is what you would want to keep — the answer, drawn.
The ball is not. It marks where the animation has got to, and in a saved picture it would be a red dot with no explanation. So it is a <div> in the overlay, positioned in world coordinates, and the document never hears about it.
The covers over the prizes are not either. They are a game rule, not ink. Turning them off changes nothing in the document, which is why doing so is not something undo has an opinion about.
.hc-app-ball {
position: absolute;
left: 0;
top: 0;
/* The overlay already carries the viewport transform, so a world
coordinate is all the ball needs to travel with the ladder. */
}This division is the whole architecture in miniature: shapes are content, the overlay is the application, and the two are kept apart deliberately rather than by accident. Concepts →
Animating without touching the document
The trail grows sixty times a second. Writing it to the document at that rate would rebuild the immutable tree every frame and leave a history entry each time — for something the user did not even edit.
It goes to the ephemeral layer instead, exactly as a drag does. The shape is created once outside the history, and every frame after that is an overlay write that costs nothing and records nothing.
// Outside the history: a run is something you watch, not an edit.
trail = editor.transact(
() => editor.createShape({ type: 'path', ...geometry(points.slice(0, 2)) }),
{ addToHistory: false },
)
const step = (now: number) => {
travelled = Math.min(length, travelled + ((now - last) / 1000) * SPEED)
editor.setEphemeral(new Map([[trail, geometry(prefix(points, travelled).points)]]))
animation = requestAnimationFrame(step)
}The geometry comes from strokeFromPoints, the same function the freehand tool uses, with fitting turned off — the ladder is made of straight segments, and smoothing them would draw a nice curve through the wrong answer. Tools →
A rung is an ordinary line that knows what it is
There is no rung shape type. A rung is a line with something written in meta:
editor.createShape({
type: 'line',
meta: { role: 'rung', row, gap },
// …
})meta is a free-form slot on every shape that the library never reads or interprets. It is the right place for the part of a model that is the application's business — here, which slot on the ladder a given line occupies — and it serialises with the shape, so a saved ladder is still a ladder when it comes back.
The board is then read out of the document rather than kept in a variable beside it:
function readRungs(editor: EditorInstance): Map<string, ShapeId> {
const out = new Map<string, ShapeId>()
for (const id of editor.getChildren(null)) {
const meta = metaOf(editor, id)
if (meta.role === 'rung') out.set(rungKey(meta.row, meta.gap), id)
}
return out
}That is deliberate. Undo, redo and loading a file all rewrite the document without asking the application first, and a cached copy of the board would be wrong exactly when someone pressed undo.
A new rung reshuffles the prizes
Adding a rung changes where runs end, so anything already revealed is now a spoiler. Laying a rung — by hand or with Scatter rungs — covers every prize again and deals them afresh. The rung and the new prizes are written in one transaction, so a single undo takes back both.
editor.transact(() => {
addRung(row, gap)
reshuffle() // new prizes, all covered
})Removing a rung does not reshuffle: taking a line away is how you correct a mistake, and it should not cost the round.
The rule that makes the puzzle work
Rungs are not allowed to touch. If two met at the same height the walk would have a choice to make, and amidakuji works precisely because it never does: with no touching rungs the walk is a permutation, so no two starting letters can ever land on the same prize.
if (rungs.has(rungKey(row, gap - 1)) || rungs.has(rungKey(row, gap + 1))) {
say('Rungs cannot touch.')
return
}Source
The application
import type {
Editor as EditorInstance,
HcPointerEvent,
ShapeId,
Tool,
Vec,
} from '@headless-canvas/core'
import { Editor, strokeFromPoints } from '@headless-canvas/core'
import { createDefaultControls } from '@headless-canvas/ui'
import '@headless-canvas/ui/styles.css'
import type { Demo } from '../demos/types'
import { t } from '../demos/types'
import { button, slider } from '../demos/ui'
import { appScaffold, download } from './chrome'
/**
* Amidakuji — ghost leg.
*
* Click between two verticals to lay a rung; click a name at the top and a ball
* rolls down, taking every rung it meets, to whatever is waiting underneath.
*
* The interesting decision here is what belongs to the document and what does
* not. The ladder and the trail the ball leaves are drawings: you would want
* them in an exported PNG, so they are shapes. The ball itself and the covers
* hiding the prizes are neither — they are the application telling you what is
* happening — so they are DOM in the overlay, and the document never hears
* about them (invariant 1).
*/
const MIN_LEGS = 3
const MAX_LEGS = 8
const LEG_GAP = 76
const ORIGIN_X = 56
const TOP_Y = 74
const BOTTOM_Y = 396
const ROWS = 11
const LADDER_INK = '#334155'
const RUNG_INK = '#0f766e'
const TRAIL_INK = '#e11d48'
/** Screen pixels within which a click counts as aiming at a rung slot. */
const AIM_PX = 22
/** World units per second the ball travels. */
const SPEED = 460
const legX = (leg: number): number => ORIGIN_X + leg * LEG_GAP
const rowY = (row: number): number => TOP_Y + ((BOTTOM_Y - TOP_Y) * (row + 1)) / (ROWS + 1)
const rungKey = (row: number, gap: number): string => `${row}:${gap}`
/**
* `meta` is the shape's free-form slot, which the library never reads. It is
* how this application says "this line is rung 3 in gap 1" without inventing a
* shape type for something that is an ordinary line.
*/
interface Meta extends Record<string, unknown> {
role?: string
leg?: number
row?: number
gap?: number
}
const metaOf = (editor: EditorInstance, id: ShapeId): Meta =>
(editor.getShape(id)?.meta ?? {}) as Meta
/** Rung slots currently occupied, read back from the document. */
function readRungs(editor: EditorInstance): Map<string, ShapeId> {
const out = new Map<string, ShapeId>()
for (const id of editor.getChildren(null)) {
const meta = metaOf(editor, id)
if (meta.role === 'rung' && meta.row !== undefined && meta.gap !== undefined) {
out.set(rungKey(meta.row, meta.gap), id)
}
}
return out
}
function shapesWithRole(editor: EditorInstance, role: string): ShapeId[] {
return editor.getChildren(null).filter((id) => metaOf(editor, id).role === role)
}
/**
* The walk down the ladder.
*
* At every row the runner takes a rung on its left if there is one, otherwise a
* rung on its right, otherwise carries straight on. Rungs never touch, so the
* two can never both apply and the result is a permutation.
*/
function walk(rungs: Map<string, ShapeId>, start: number): { points: Vec[]; end: number } {
let leg = start
const points: Vec[] = [{ x: legX(leg), y: TOP_Y }]
for (let row = 0; row < ROWS; row++) {
const y = rowY(row)
const left = rungs.has(rungKey(row, leg - 1))
const right = rungs.has(rungKey(row, leg))
if (!left && !right) continue
points.push({ x: legX(leg), y })
leg += left ? -1 : 1
points.push({ x: legX(leg), y })
}
points.push({ x: legX(leg), y: BOTTOM_Y })
return { points, end: leg }
}
/** The prefix of a polyline `distance` along it, for the growing trail. */
function prefix(points: readonly Vec[], distance: number): { points: Vec[]; head: Vec } {
const out: Vec[] = [points[0] as Vec]
let left = distance
for (let i = 1; i < points.length; i++) {
const from = points[i - 1] as Vec
const to = points[i] as Vec
const span = Math.hypot(to.x - from.x, to.y - from.y)
if (span >= left) {
const ratio = span === 0 ? 0 : left / span
const head = { x: from.x + (to.x - from.x) * ratio, y: from.y + (to.y - from.y) * ratio }
out.push(head)
return { points: out, head }
}
left -= span
out.push(to)
}
return { points: out, head: points[points.length - 1] as Vec }
}
const totalLength = (points: readonly Vec[]): number => {
let sum = 0
for (let i = 1; i < points.length; i++) {
const from = points[i - 1] as Vec
const to = points[i] as Vec
sum += Math.hypot(to.x - from.x, to.y - from.y)
}
return sum
}
interface Board {
legs: number
onAim(row: number, gap: number): void
onStart(leg: number): void
}
/** Clicks: a name at the top starts a run, anywhere else aims at a rung slot. */
class LadderTool implements Tool {
readonly id = 'ladder'
constructor(
private readonly editor: EditorInstance,
private readonly board: Board,
) {}
onPointerDown(event: HcPointerEvent): void {
if (event.button !== 0) return
if (event.target !== null) {
const meta = metaOf(this.editor, event.target)
if (meta.role === 'top' && meta.leg !== undefined) {
this.board.onStart(meta.leg)
return
}
}
const tolerance = AIM_PX / this.editor.viewport.camera.z
let row = -1
let best = tolerance
for (let candidate = 0; candidate < ROWS; candidate++) {
const distance = Math.abs(rowY(candidate) - event.world.y)
if (distance < best) {
best = distance
row = candidate
}
}
if (row < 0) return
const gap = Math.floor((event.world.x - ORIGIN_X) / LEG_GAP)
if (gap < 0 || gap >= this.board.legs - 1) return
this.board.onAim(row, gap)
}
}
export const amidakuji: Demo = ({ root, lang }) => {
const _ = t(lang)
const { bar, stage, setStatus, hint, say } = appScaffold(root, { plain: true })
const editor = new Editor({ container: stage })
const controls = createDefaultControls(editor)
let legs = 6
let prizes: string[] = []
let hidden = true
let trail: ShapeId | null = null
let animation: number | null = null
const ball = document.createElement('div')
ball.className = 'hc-app-ball'
ball.hidden = true
editor.overlayElement.append(ball)
const covers: HTMLElement[] = []
const winner = () => _(['WIN', 'あたり'])
const blank = () => _(['—', 'はずれ'])
const name = (leg: number) => String.fromCharCode(65 + leg)
const freshPrizes = (count: number): string[] => {
const out = Array.from({ length: count }, () => blank())
out[Math.floor(Math.random() * count)] = winner()
return out
}
// --- drawing the ladder ---------------------------------------------------
const vertical = (x: number, y0: number, y1: number, colour: string, width: number, meta: Meta) =>
editor.createShape({
type: 'line',
x: x - width / 2,
y: y0,
width,
height: y1 - y0,
meta,
props: {
start: { x: 0.5, y: 0 },
end: { x: 0.5, y: 1 },
stroke: { color: colour, width, cap: 'round' },
shadow: null,
},
})
const label = (leg: number, text: string, y: number, role: 'top' | 'bottom') =>
editor.createShape({
type: 'text',
x: legX(leg) - 36,
y,
width: 72,
height: 26,
meta: { role, leg },
props: {
text,
fontSize: 18,
fontWeight: role === 'top' ? 600 : 400,
align: 'center',
fill: { type: 'solid', color: role === 'top' ? '#0f172a' : '#7c2d12' },
},
})
const rebuild = (): void => {
stopRun()
editor.transact(() => {
editor.deleteShapes(editor.getChildren(null))
for (let leg = 0; leg < legs; leg++) {
vertical(legX(leg), TOP_Y, BOTTOM_Y, LADDER_INK, 3, { role: 'leg', leg })
label(leg, name(leg), TOP_Y - 34, 'top')
label(leg, prizes[leg] ?? blank(), BOTTOM_Y + 10, 'bottom')
}
})
layoutCovers()
editor.viewport.zoomToFit(undefined, 48)
}
const writePrizes = (): void => {
editor.transact(() => {
for (const id of shapesWithRole(editor, 'bottom')) {
const meta = metaOf(editor, id)
const shape = editor.getShape<'text'>(id)
if (!shape || meta.leg === undefined) continue
editor.updateShape(id, { props: { ...shape.props, text: prizes[meta.leg] ?? blank() } })
}
})
}
// --- the covers, which are not part of the drawing ------------------------
const layoutCovers = (): void => {
while (covers.length < legs) {
const cover = document.createElement('div')
cover.className = 'hc-app-cover'
cover.textContent = '?'
editor.overlayElement.append(cover)
covers.push(cover)
}
covers.forEach((cover, leg) => {
cover.hidden = !hidden || leg >= legs
cover.style.transform = `translate(${legX(leg) - 36}px, ${BOTTOM_Y + 8}px)`
})
}
// --- the run --------------------------------------------------------------
const clearTrail = (): void => {
if (trail === null) return
const doomed = trail
trail = null
editor.clearEphemeral()
editor.transact(() => editor.deleteShapes([doomed]), { addToHistory: false })
}
const stopRun = (): void => {
if (animation !== null) cancelAnimationFrame(animation)
animation = null
ball.hidden = true
clearTrail()
}
const geometry = (points: readonly Vec[]) =>
// Straight segments only, so the fitting is turned off: what is drawn is
// the ladder itself rather than a smoothed impression of it.
strokeFromPoints(points, { tolerance: 0, smoothing: 0, color: TRAIL_INK, width: 7 })
const run = (start: number): void => {
stopRun()
const rungs = readRungs(editor)
const { points, end } = walk(rungs, start)
const length = totalLength(points)
// Outside the history: a run is something you watch, not an edit.
trail = editor.transact(
() =>
editor.createShape({
type: 'path',
meta: { role: 'trail' },
...geometry(points.slice(0, 2)),
}),
{ addToHistory: false },
)
ball.hidden = false
let travelled = 0
let last = performance.now()
const step = (now: number) => {
travelled = Math.min(length, travelled + ((now - last) / 1000) * SPEED)
last = now
const grown = prefix(points, travelled)
if (trail !== null && grown.points.length > 1) {
editor.setEphemeral(new Map([[trail, geometry(grown.points)]]))
}
ball.style.transform = `translate(${grown.head.x}px, ${grown.head.y}px)`
if (travelled < length) {
animation = requestAnimationFrame(step)
return
}
animation = null
covers[end]?.setAttribute('hidden', '')
say(_([`${name(start)} → ${prizes[end] ?? ''}`, `${name(start)} → ${prizes[end] ?? ''}`]))
}
animation = requestAnimationFrame(step)
}
// --- the board ------------------------------------------------------------
const addRung = (row: number, gap: number): void => {
const y = rowY(row)
editor.createShape({
type: 'line',
x: legX(gap),
y: y - 2,
width: LEG_GAP,
height: 4,
meta: { role: 'rung', row, gap },
props: {
start: { x: 0, y: 0.5 },
end: { x: 1, y: 0.5 },
stroke: { color: RUNG_INK, width: 4, cap: 'round' },
shadow: null,
},
})
}
/**
* A new rung changes where every run ends, so whatever anyone has already
* seen of the prizes is now a spoiler. They are covered again and dealt
* afresh — in the same transaction as the rung, so one undo takes back both.
*/
const reshuffle = (): void => {
prizes = freshPrizes(legs)
writePrizes()
setHidden(true)
}
const toggleRung = (row: number, gap: number): void => {
stopRun()
const rungs = readRungs(editor)
const existing = rungs.get(rungKey(row, gap))
if (existing) {
editor.deleteShapes([existing])
return
}
// Touching rungs would make the walk ambiguous, so neighbours block.
if (rungs.has(rungKey(row, gap - 1)) || rungs.has(rungKey(row, gap + 1))) {
say(_(['Rungs cannot touch.', '横線どうしは隣り合えません。']))
return
}
editor.transact(() => {
addRung(row, gap)
reshuffle()
})
say(
_([
'New rung — prizes hidden and shuffled.',
'横線を追加したので、あたりを隠してシャッフルしました。',
]),
)
}
editor.tools.register(
'ladder',
(e) =>
new LadderTool(e, {
get legs() {
return legs
},
onAim: toggleRung,
onStart: run,
}),
)
editor.tools.setCurrent('ladder')
// --- controls -------------------------------------------------------------
slider(
bar,
_(['People', '人数']),
{ min: MIN_LEGS, max: MAX_LEGS, step: 1, value: legs },
(value) => {
legs = value
prizes = freshPrizes(legs)
rebuild()
},
)
button(bar, _(['Scatter rungs', '横線をランダムに']), () => {
stopRun()
editor.transact(() => {
for (const id of shapesWithRole(editor, 'rung')) editor.deleteShapes([id])
const taken = new Set<string>()
for (let row = 0; row < ROWS; row++) {
for (let gap = 0; gap < legs - 1; gap++) {
if (Math.random() > 0.34) continue
if (taken.has(rungKey(row, gap - 1)) || taken.has(rungKey(row, gap + 1))) continue
taken.add(rungKey(row, gap))
addRung(row, gap)
}
}
reshuffle()
})
say(
_([
'New rungs — prizes hidden and shuffled.',
'横線を引き直したので、あたりを隠してシャッフルしました。',
]),
)
})
button(bar, _(['Shuffle prizes', 'あたりをシャッフル']), () => {
stopRun()
prizes = freshPrizes(legs)
writePrizes()
layoutCovers()
})
/**
* Not the demos' `toggle`: that one keeps its state to itself, and a new rung
* has to be able to switch this back on. The button reads `hidden` instead of
* holding a copy, so there is only one answer to "are the prizes covered".
*/
const hiddenButton = button(bar, '', () => setHidden(!hidden))
function setHidden(value: boolean): void {
hidden = value
hiddenButton.textContent = value
? _(['Prizes hidden', 'あたりを隠す'])
: _(['Prizes shown', 'あたりを表示'])
hiddenButton.setAttribute('aria-pressed', String(value))
layoutCovers()
}
setHidden(hidden)
button(bar, _(['Reset', 'リセット']), () => {
stopRun()
layoutCovers()
say('')
})
button(bar, _(['Save PNG', 'PNG保存']), () => {
editor
.export({ format: 'png', scale: 2, background: '#ffffff', padding: 28 })
.then((blob) => download('amidakuji.png', blob))
.catch(() => undefined)
})
hint(
_([
'Click between two verticals to lay a rung. Click a letter at the top to send a ball down.',
'縦線と縦線のあいだをクリックすると横線が引けます。上の文字をクリックすると玉が転がります。',
]),
)
const stop = editor.subscribe(() => {
const rungs = shapesWithRole(editor, 'rung').length
setStatus(
_([
`${legs} people · ${rungs} rung(s) · ${editor.overlayElement.querySelectorAll('*').length} overlay DOM nodes`,
`${legs} 人 · 横線 ${rungs} 本 · オーバーレイの DOM ノード ${editor.overlayElement.querySelectorAll('*').length} 個`,
]),
)
})
prizes = freshPrizes(legs)
rebuild()
return {
dispose() {
stop()
stopRun()
ball.remove()
for (const cover of covers) cover.remove()
covers.length = 0
controls.dispose()
editor.dispose()
},
}
}