A mini sudoku spelled in letters, with words hidden in it.
01 · SUDOKUEach of the six letters appears once in every row, every column and every 2×3 box.
02 · WORDSEach word starts in the cell ringed in its colour and is spelled one step at a time, up, down, left or right. Its outline grows with the letters.
03 · TILESBelow the board is one tile per letter. Pull it and it divides: the copy goes on a cell. Bring a tile back to its letter and they merge.
Parloku is Pietro's word-flavoured
mini sudoku: a 6×6 grid, six letters instead of six digits,
and a few words threaded through the grid as extra clues. The puzzles come from
puzzlepaw, where they are crafted in a terminal UI.
This page is a nimib notebook. Everything below is the Nim source
of the game you just played. Most blocks run twice: natively while nimib builds the page (that is where the
printed outputs come from) and in your browser, compiled with nim js. The dragging uses a tiny port of
Declarative Dragging (Horowitz, Blinn & Heer, UIST 2026):
nobody wrote a drop handler, the code only lists the boards a drag can reach.
01 · THE MODELSix letters, thirty-six tiles
A puzzle is six letters, a few given letters and the word clues. A board says which tile sits in each cell.
There are 36 tiles, six copies of each letter, so tile id is copy id mod 6 of letter id div 6.
A given letter is just a tile that is glued down.
Puzzles travel as a short code that fits in a URL, so a future builder (or a daily puzzle) only has to hand out a link.
The code is LETTERS_GIVENS_WORDS (the format puzzlepaw uses too): givens are row, column, letter and words are
row, column, word, both 0-based and separated by dots.
import std/[math, strformat, strutils, sequtils]
typeCoord = tuple[row, col: int]
Given = object
pos: Coord
letter: charClue = object
pos: Coord
word: stringPuzzle = object
letters: string# the six letters
givens: seq[Given]
clues: seq[Clue]
Grid = array[36, char] # '.' for an empty cellBoard = object
cells: array[36, int] # tile id in each cell, -1 when empty
moves: intfunc idx(r, c: int): int = r * 6 + c
func idx(p: Coord): int = idx(p.row, p.col)
proc encode(p: Puzzle): string =
## LETTERS_GIVENS_WORDS, the code format shared with puzzlepaw
p.letters & "_" & p.givens.mapIt(&"{it.pos.row}{it.pos.col}{it.letter}").join(".") &
"_" & p.clues.mapIt(&"{it.pos.row}{it.pos.col}{it.word}").join(".")
proc decode(code: string): Puzzle =
var c = code.strip(chars = {'?', '#', ' '})for part in c.split('&'): # a whole query string: take its p= value, wherever it isif part.startsWith("p="): c = part[2 .. ^1]
let parts = c.toUpperAscii.split('_')if parts.len != 3: returnresult.letters = parts[0]
for kind in1 .. 2:
for item in parts[kind].split('.'):if item.len >= 3and item[0] in'0'..'5'and item[1] in'0'..'5':
let pos = (row: ord(item[0]) - ord('0'), col: ord(item[1]) - ord('0'))
if kind == 1: result.givens.add Given(pos: pos, letter: item[2])
else: result.clues.add Clue(pos: pos, word: item[2 .. ^1])
proc valid(p: Puzzle): bool =
p.letters.len == 6and p.letters.deduplicate.len == 6and
p.givens.allIt(it.letter in p.letters and it.pos.row in0..5and it.pos.col in0..5) and
p.clues.allIt(it.word.allIt(it in p.letters) and it.pos.row in0..5and it.pos.col in0..5)
var puz: Puzzle# the puzzle being playedproc letterOf(id: int): char = puz.letters[id div6]
proc isGiven(cell: int): bool = puz.givens.anyIt(idx(it.pos) == cell)
proc grid(b: Board): Grid =
for c, id in b.cells:
result[c] = '.'if id >= 0: result[c] = letterOf(id)
proc boardOf(p: Puzzle, g: Grid): Board =
## a board holding the letters of `g`, using the lowest free copy of each lettervar used: array[6, int]
for c in0 ..< 36:
result.cells[c] = -1let li = p.letters.find(g[c])
if li >= 0and used[li] < 6:
result.cells[c] = li * 6 + used[li]
inc used[li]
proc startGrid(p: Puzzle): Grid =
for c inresult.mitems: c = '.'for gv in p.givens: result[idx(gv.pos)] = gv.letter
The puzzles are the JSON files crafted with puzzlepaw, read at build time and turned into codes:
var builtin: seq[(string, string)] # (title, code)var answerKeys: seq[Grid] # the solutions stored in the JSON filesvar files = toSeq(walkFiles(currentSourcePath().parentDir.parentDir / "data" / "puzzle*.json"))
files.sort()
for f in files:
let all = parseFile(f)
let j = all["problem"]
var p = Puzzle(letters: j["letters"].mapIt(it.getStr).join)
for h in j["letterHints"]:
p.givens.add Given(pos: (h["pos"][0].getInt, h["pos"][1].getInt), letter: h["letter"].getStr[0])
for h in j["wordHints"]:
p.clues.add Clue(pos: (h["pos"][0].getInt, h["pos"][1].getInt), word: h["word"].getStr)
builtin.add (p.clues.mapIt(it.word).join(" · "), p.encode)
var key: Gridfor r, row in all["solution"].getElems:
for c, ch in row.getElems: key[idx(r, c)] = ch.getStr[0]
answerKeys.add key
for (title, code) in builtin:
echo title, "\n ?p=", code, " valid: ", decode(code).valid
ITALIA · PASTA · ALPI
?p=AILPST_51S.23L.04P_00ITALIA.20PASTA.40ALPI valid: true
AMICA · GIOCO · MAGIA
?p=ACGIMO_41I.42O.54I.55O_00AMICA.11GIOCO.05MAGIA valid: true
02 · THE RULES, AS CODEChecking, tracing, solving
Three functions carry the rules. clashes finds repeated letters in a row, column or box (they glow red on the board).
trace walks a clue through the grid with a depth-first search and returns the longest prefix it can spell, so
the clue chips light up letter by letter and the path is drawn over the tiles.
With wild = true an empty cell matches any letter, which is what a solver needs to prune early.
proc makeUnits(): seq[seq[int]] =
for r in0 .. 5: result.add toSeq(0 .. 5).mapIt(idx(r, it))
for c in0 .. 5: result.add toSeq(0 .. 5).mapIt(idx(it, c))
for b in0 .. 5:
var u: seq[int]
for r in0 .. 1:
for c in0 .. 2: u.add idx((b div2) * 2 + r, (b mod2) * 3 + c)
result.add u
let units = makeUnits()
proc peers(cell: int): seq[int] =
for u in units:
if cell in u:
for j in u:
if j != cell and j notinresult: result.add j
let peersOf = toSeq(0 ..< 36).mapIt(peers(it))
proc clashes(g: Grid): seq[int] =
for c in0 ..< 36:
if g[c] != '.'and peersOf[c].anyIt(g[it] == g[c]): result.add c
proc trace(g: Grid, clue: Clue, wild = false, revisit = false): seq[int] =
## longest prefix of the word that can be walked from its start cell.## The rules let a path come back to a cell it already used (`revisit`, for the solver); the board's## outlines don't, or G-I-O-C would read as GIOCO by stepping back onto the Oifnot revisit:
var best, path: seq[int]
let word = clue.word
proc walk(cell, i: int) =
if best.len == word.len or cell in path: returnlet ok = g[cell] == word[i] or (wild and g[cell] == '.')
ifnot ok: return
path.add cell
if path.len > best.len: best = path
if i + 1 < word.len:
let (r, c) = (cell div6, cell mod6)
if r > 0: walk(cell - 6, i + 1)
if r < 5: walk(cell + 6, i + 1)
if c > 0: walk(cell - 1, i + 1)
if c < 5: walk(cell + 1, i + 1)
discard path.pop
walk(idx(clue.pos), 0)
return best
var best, path: seq[int]
let word = clue.word
proc walk(cell, i: int) =
if best.len == word.len: returnlet ok = g[cell] == word[i] or (wild and g[cell] == '.')
ifnot ok: return
path.add cell
if path.len > best.len: best = path
if i + 1 < word.len:
let (r, c) = (cell div6, cell mod6)
if r > 0: walk(cell - 6, i + 1)
if r < 5: walk(cell + 6, i + 1)
if c > 0: walk(cell - 1, i + 1)
if c < 5: walk(cell + 1, i + 1)
discard path.pop
walk(idx(clue.pos), 0)
best
proc solved(p: Puzzle, g: Grid): bool =
'.'notin g and g.clashes.len == 0and p.clues.allIt(g.trace(it, revisit = true).len == it.word.len)
proc solve(p: Puzzle, limit = 2): seq[Grid] =
## backtracking with "fewest candidates first", pruning on the clues;## stops after `limit` solutions (2 is enough to tell unique from ambiguous)var g = startGrid(p)
var sols: seq[Grid]
proc fits(g: Grid, cell: int, ch: char): bool = peersOf[cell].allIt(g[it] != ch)
proc cluesOk(g: Grid): bool = p.clues.allIt(g.trace(it, wild = true, revisit = true).len == it.word.len)
proc go(g: varGrid) =
if sols.len >= limit: returnvar best = -1var bestN = 99for c in0 ..< 36:
if g[c] == '.':
let n = p.letters.countIt(fits(g, c, it))
if n < bestN: (best, bestN) = (c, n)
if best < 0:
if solved(p, g): sols.add g
returnfor ch in p.letters:
if fits(g, best, ch):
g[best] = ch
if cluesOk(g): go(g)
g[best] = '.'if g.clashes.len == 0and cluesOk(g): go(g)
sols
At build time the solver checks each puzzle has exactly one solution, and traces the clues through it:
for i, (title, code) in builtin:
let p = decode(code)
let sols = p.solve
let verdict = if sols.len == 1: " (unique)"else: "s found, stopped"let key = if sols[0] == answerKeys[i]: "matches"else: "DIFFERS FROM"echo title, ": ", sols.len, " solution", verdict, ", ", key, " the answer key in data/"for clue in p.clues:
let path = sols[0].trace(clue).mapIt(&"r{it div 6 + 1}c{it mod 6 + 1}")
echo &" {clue.word:<7}", path.join(" → ")
ITALIA · PASTA · ALPI: 1 solution (unique), matches the answer key in data/
ITALIA r1c1 → r1c2 → r1c3 → r2c3 → r2c4 → r2c5
PASTA r3c1 → r3c2 → r3c3 → r4c3 → r4c4
ALPI r5c1 → r5c2 → r5c3 → r6c3
AMICA · GIOCO · MAGIA: 1 solution (unique), matches the answer key in data/
AMICA r1c1 → r2c1 → r3c1 → r3c2 → r3c3
GIOCO r2c2 → r2c3 → r2c4 → r2c5 → r3c5
MAGIA r1c6 → r2c6 → r3c6 → r4c6 → r4c5
The solver is also the seed of a puzzle builder: pick letters, givens and words, and it tells you whether the
setup has zero, one or many solutions. In the game it powers the 💡 button, which glides one correct tile into place.
03 · A LOGICAL PATHSolving like a person
Backtracking finds the answer but can't explain it. The 🎓 tutorial needs moves a person can see, so here is
Pietro's method as code. First put the first letter of every word in its ringed cell. Then follow each word
letter by letter: the next letter sits next to the previous one, has to fit the sudoku, and has to leave a way
to finish the word (spellings lists every walk that still could). When only one cell qualifies, the letter is forced.
When no word moves, plain sudoku singles take over: a cell with one candidate left, or a letter with one place left in a row,
column or box. The tutorial asks for the next step after every drop, so it copes with any order and even with mistakes.
typeStepKind = enum skStart, skWord, skNaked, skHidden, skFix, skReveal
Step = object
kind: StepKind
cell: int# where the tile goes (-1: no step)
letter: char
clue: int# start and word steps: which clue
unit: int# hidden singles: which row, column or box
path: seq[int] # word steps: the word so far, ending in `cell`
dead: seq[int] # word steps: neighbours that fit the sudoku but can't finish the wordvar lang = "en"# "en" or "it", set by the pageprocL(en, it: string): string = (if lang == "it": it else: en)
proc cands(p: Puzzle, g: Grid, c: int): string =
if g[c] != '.': return $g[c]
for ch in p.letters:
if peersOf[c].allIt(g[it] != ch): result.add ch
proc neighbours(at: int): seq[int] =
for nb in [at - 6, at + 6, at - 1, at + 1]:
if nb >= 0and nb < 36and (abs(nb - at) == 6or nb div6 == at div6): result.add nb
proc spellings(p: Puzzle, g: Grid, clue: Clue): seq[seq[int]] =
## every walk that could still spell the clue: each cell holds its letter or could take it,## and the walk never puts the same letter twice in a row, column or boxvar found: seq[seq[int]]
var cur: seq[int]
let word = clue.word
proc walk(cell, i: int) =
let ch = word[i]
ifnot (g[cell] == ch or (g[cell] == '.'and ch in cands(p, g, cell))): returnfor k, prev in cur:
if prev == cell and word[k] != ch: returnif prev != cell and word[k] == ch and prev in peersOf[cell]: return
cur.add cell
if cur.len == word.len: found.add cur
else:
for nb in neighbours(cell): walk(nb, i + 1)
discard cur.pop
walk(idx(clue.pos), 0)
found
proc wordStep(p: Puzzle, g: Grid): Step =
result.cell = -1for i, clue in p.clues:
let start = idx(clue.pos)
if g[start] == '.': returnStep(kind: skStart, cell: start, letter: clue.word[0], clue: i, path: @[start])
for i, clue in p.clues:
let sp = spellings(p, g, clue)
var path = @[idx(clue.pos)]
for k in1 ..< clue.word.len:
let ch = clue.word[k]
let at = path[^1]
let fits = neighbours(at).filterIt(g[it] == ch or (g[it] == '.'and ch in cands(p, g, it)))
let next = sp.mapIt(it[k]).deduplicate
if next.len != 1: break
path.add next[0]
if g[next[0]] == '.':
returnStep(kind: skWord, cell: next[0], letter: ch, clue: i, path: path,
dead: fits.filterIt(it != next[0]))
proc singleStep(p: Puzzle, g: Grid): Step =
result.cell = -1for c in0 ..< 36:
if g[c] == '.':
let cs = cands(p, g, c)
if cs.len == 1: returnStep(kind: skNaked, cell: c, letter: cs[0])
for u, cells in units:
for ch in p.letters:
if cells.anyIt(g[it] == ch): continuelet spots = cells.filterIt(g[it] == '.'and ch in cands(p, g, it))
if spots.len == 1: returnStep(kind: skHidden, cell: spots[0], letter: ch, unit: u)
proc logicalStep(p: Puzzle, g: Grid, solution: Grid): Step =
## the next move a person would make; a wrong tile is pointed out firstfor c in0 ..< 36:
if g[c] != '.'and solution[c] != '.'and g[c] != solution[c]:
returnStep(kind: skFix, cell: c, letter: g[c])
result = wordStep(p, g)
ifresult.cell < 0: result = singleStep(p, g)
ifresult.cell < 0:
for c in0 ..< 36:
if g[c] == '.'and solution[c] != '.': returnStep(kind: skReveal, cell: c, letter: solution[c])
proc rc(c: int): string = &"r{c div 6 + 1}c{c mod 6 + 1}"proc nth(k: int): string =
const en = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth"]
const it = ["prima", "seconda", "terza", "quarta", "quinta", "sesta", "settima", "ottava"]
if k < 1or k > 8: return $k
L(en[k - 1], it[k - 1])
proc inUnit(u: int): string =
## "in row 5" / "nella riga 5"if u < 6: L(&"in row {u + 1}", &"nella riga {u + 1}")
elif u < 12: L(&"in column {u - 5}", &"nella colonna {u - 5}")
else: L(&"in box {u - 11}", &"nel riquadro {u - 11}")
proc blockUnit(g: Grid, cell: int, ch: char): int =
## why `ch` can't go in `cell`: -1 the cell is taken, else the unit that already has `ch`if g[cell] != '.': return -1for u, cells in units:
if cell in cells and cells.anyIt(g[it] == ch): return u
-2proc an(ch: char): string = (if ch in"AEFHILMNORSX": "an"else: "a")
proc blocked(g: Grid, cells: seq[int], ch: char): seq[string] =
## "r3c1 and r5c1 are in column 1, which already has an A"var keys: seq[int]
for c in cells:
let k = blockUnit(g, c, ch)
if k notin keys: keys.add k
for k in keys:
let group = cells.filterIt(blockUnit(g, it, ch) == k)
let names = group.mapIt(rc(it)).join(L(" and ", " e "))
let isEn = if group.len > 1: "are"else: "is"let isIt = if group.len > 1: "sono"else: "è"let takenIt = if group.len > 1: "occupate"else: "occupata"if k == -1:
result.add L(&"{names} {isEn} taken", &"{names} {isIt} {takenIt}")
elif k >= 0:
result.add L(&"{names} {isEn} {inUnit(k)}, which already has {an(ch)} {ch}",
&"{names} {isIt} {inUnit(k)}, che ha già una {ch}")
proc explain(p: Puzzle, g: Grid, s: Step): string =
let w = if s.clue < p.clues.len: p.clues[s.clue].word else: ""case s.kind
of skStart:
L(&"<b>{w}</b> starts in cell {s.clue + 1}, so {rc(s.cell)} is <b>{s.letter}</b>.",
&"<b>{w}</b> parte dalla casella {s.clue + 1}, quindi {rc(s.cell)} è <b>{s.letter}</b>.")
of skWord:
let k = s.path.len
let prev = s.path[^2]
var notes: seq[string]
var others: seq[int]
for o in neighbours(prev):
if o == s.cell or o in s.path: continueif o notin s.dead:
others.add o
continuevar g2 = g
g2[o] = s.letter
let nxt = w[k]
let room = neighbours(o).filterIt(g2[it] == nxt or (g2[it] == '.'and nxt in cands(p, g2, it)))
if room.len == 0:
notes.add L(&"{rc(o)} fits {s.letter}, but then the next letter, {nxt}, would have no room",
&"in {rc(o)} la {s.letter} ci starebbe, ma poi la lettera dopo, {nxt}, non avrebbe posto")
else:
notes.add L(&"{rc(o)} fits {s.letter}, but from there {w} can't be finished",
&"in {rc(o)} la {s.letter} ci starebbe, ma da lì {w} non si può completare")
notes = blocked(g, others, s.letter) & notes
L(&"The {nth(k)} letter of <b>{w}</b>, <b>{s.letter}</b>, sits next to {rc(prev)}. ",
&"La {nth(k)} lettera di <b>{w}</b>, <b>{s.letter}</b>, sta accanto a {rc(prev)}. ") &
notes.join("; ") & L(&". So {rc(s.cell)} is <b>{s.letter}</b>.", &". Quindi {rc(s.cell)} è <b>{s.letter}</b>.")
of skNaked:
let seen = p.letters.filterIt(it != s.letter).join(" ")
L(&"{rc(s.cell)} already sees {seen} in its row, column and box: only <b>{s.letter}</b> fits.",
&"{rc(s.cell)} vede già {seen} nella sua riga, colonna e riquadro: ci sta solo la <b>{s.letter}</b>.")
of skHidden:
L(&"{inUnit(s.unit).capitalizeAscii}, <b>{s.letter}</b> fits only in {rc(s.cell)}.",
&"{inUnit(s.unit).capitalizeAscii}, la <b>{s.letter}</b> può andare solo in {rc(s.cell)}.")
of skFix:
L(&"The {s.letter} in {rc(s.cell)} is not right. Drag it back to its letter below the board.",
&"La {s.letter} in {rc(s.cell)} non è giusta. Riportala alla sua lettera sotto la griglia.")
of skReveal:
L(&"This one is tricky: {rc(s.cell)} is <b>{s.letter}</b>.", &"Questa è difficile: {rc(s.cell)} è <b>{s.letter}</b>.")
The path it finds for the first puzzle, which the tutorial walks through:
block:
let p = decode(builtin[0][1])
let sol = p.solve[0]
var g = startGrid(p)
var n = 0while'.'in g:
let s = logicalStep(p, g, sol)
inc n
echo &"{n:>2}. ", explain(p, g, s).replace("<b>", "").replace("</b>", "")
g[s.cell] = s.letter
1. ITALIA starts in cell 1, so r1c1 is I.
2. PASTA starts in cell 2, so r3c1 is P.
3. ALPI starts in cell 3, so r5c1 is A.
4. The second letter of PASTA, A, sits next to r3c1. r2c1 and r4c1 are in column 1, which already has an A. So r3c2 is A.
5. The second letter of ITALIA, T, sits next to r1c1. r2c1 fits T, but then the next letter, A, would have no room. So r1c2 is T.
6. The third letter of ITALIA, A, sits next to r1c2. r2c2 is in column 2, which already has an A. So r1c3 is A.
7. The fourth letter of ITALIA, L, sits next to r1c3. r1c4 is in column 4, which already has an L. So r2c3 is L.
8. The third letter of PASTA, S, sits next to r3c2. r2c2 and r4c2 are in column 2, which already has an S. So r3c3 is S.
9. The fifth letter of ITALIA, I, sits next to r2c3. r3c3 is taken; r2c2 is in box 1, which already has an I. So r2c4 is I.
10. The sixth letter of ITALIA, A, sits next to r2c4. r1c4 is in row 1, which already has an A; r3c4 is taken. So r2c5 is A.
11. The fourth letter of PASTA, T, sits next to r3c3. r2c3 and r3c4 are taken. So r4c3 is T.
12. The fifth letter of PASTA, A, sits next to r4c3. r5c3 is in row 5, which already has an A; r4c2 is in column 2, which already has an A. So r4c4 is A.
13. The second letter of ALPI, L, sits next to r5c1. r4c1 fits L, but from there ALPI can't be finished; r6c1 fits L, but then the next letter, P, would have no room. So r5c2 is L.
14. The third letter of ALPI, P, sits next to r5c2. r4c2 is in box 3, which already has a P; r6c2 is taken. So r5c3 is P.
15. The fourth letter of ALPI, I, sits next to r5c3. r4c3 is taken; r5c4 is in column 4, which already has an I. So r6c3 is I.
16. r1c4 already sees A I L P T in its row, column and box: only S fits.
17. r1c6 already sees A I P S T in its row, column and box: only L fits.
18. r2c1 already sees A I L P T in its row, column and box: only S fits.
19. r2c2 already sees A I L S T in its row, column and box: only P fits.
20. r2c6 already sees A I L P S in its row, column and box: only T fits.
21. r3c6 already sees A L P S T in its row, column and box: only I fits.
22. r3c5 already sees A I L P S in its row, column and box: only T fits.
23. r4c1 already sees A I P S T in its row, column and box: only L fits.
24. r4c2 already sees A L P S T in its row, column and box: only I fits.
25. r4c5 already sees A I L P T in its row, column and box: only S fits.
26. r4c6 already sees A I L S T in its row, column and box: only P fits.
27. r5c4 already sees A I L P S in its row, column and box: only T fits.
28. r5c5 already sees A L P S T in its row, column and box: only I fits.
29. r5c6 already sees A I L P T in its row, column and box: only S fits.
30. r6c1 already sees A I L P S in its row, column and box: only T fits.
31. r6c4 already sees A I L S T in its row, column and box: only P fits.
32. r6c5 already sees A I P S T in its row, column and box: only L fits.
33. r6c6 already sees I L P S T in its row, column and box: only A fits.
04 · THE DRAG SPECListing futures instead of handling drops
The drag engine is the one from Declarative Dragging, in Nim:
a view is a pure function from model to keyed, positioned items, and a behaviour turns a list of reachable
models into a picker that chooses the one landing the held item closest to the pointer.
Tick the box and drag a tile on the board above: the dashed squares are every board this drag can reach.
show the engine (closest · within · orElse, ~60 lines)
typeVec = object
x, y: floatItem = object## one keyed element of a rendered view
key: string## identity across states (what your eye tracks)
pos: Vec## where it sits in this state
svg: string## what it looks like, drawn around (0, 0)Render[S] = proc (s: S): seq[Item]
Choice[S] = object
ok: bool
state: S
land: Vec## where the dragged element lands in `state`
dist: floatPicker[S] = object## a behaviour, instantiated at drag start
lands: seq[Vec] ## speculative landing spots
pick: proc (p: Vec): Choice[S]
Behavior[S] = proc (render: Render[S], key: string): Picker[S]
func vec(x, y: float): Vec = Vec(x: x, y: y)
func `+`(a, b: Vec): Vec = vec(a.x + b.x, a.y + b.y)
func `-`(a, b: Vec): Vec = vec(a.x - b.x, a.y - b.y)
func norm(a: Vec): float = hypot(a.x, a.y)
proc locate[S](render: Render[S], s: S, key: string): Vec =
## speculative rendering: draw `s` without painting it, report where `key` landsfor it in render(s):
if it.key == key: return it.pos
vec(NaN, NaN)
proc closest[S](states: seq[S]): Behavior[S] =
result = proc (render: Render[S], key: string): Picker[S] =
var lands: seq[Vec]
for s in states: lands.add render.locate(s, key)
result.lands = lands
result.pick = proc (p: Vec): Choice[S] =
result.dist = Inffor i, s in states:
let d = norm(lands[i] - p)
if d < result.dist:
result = Choice[S](ok: true, state: s, land: lands[i], dist: d)
proc within[S](b: Behavior[S], radius: float): Behavior[S] =
result = proc (render: Render[S], key: string): Picker[S] =
let inner = b(render, key)
result.lands = inner.lands
result.pick = proc (p: Vec): Choice[S] =
result = inner.pick(p)
ifresult.dist > radius: result.ok = falseproc orElse[S](a, b: Behavior[S]): Behavior[S] =
result = proc (render: Render[S], key: string): Picker[S] =
let pa = a(render, key)
let pb = b(render, key)
result.lands = pa.lands & pb.lands
result.pick = proc (p: Vec): Choice[S] =
result = pa.pick(p)
ifnotresult.ok: result = pb.pick(p)
The view puts each tile either in its cell or on its letter's spot below the board. All the tiles still to place sit
exactly on top of each other, so the player sees one tile per letter: pulling the top one off looks like the tile
dividing, and bringing one back looks like two tiles merging.
Six colours, one per letter. The view only names classes (g0…g5, ca, boxline, wp c1…), so the page's CSS skin picks the palette, the fonts and even the tile shape: square, rounded or round.
## colours live in the page's CSS skins: --g0..--g5 tile colours, --i0..--i5 their letters, --c0..--c4 the cluesconst cell = 78.0const bx = 66.0const by = 16.0proc cellPos(c: int): Vec = vec(bx + float(c mod6) * cell + cell / 2, by + float(c div6) * cell + cell / 2)
proc stackPos(li: int): Vec = vec(150 + float(li mod3) * 150, 546 + float(li div3) * 74) # two rows of three under the boardproc blobPath(s: float, seed: int, amp: float): string =
## a soft closed blob around the origin: 8 points with varied radii, joined by Catmull-Rom curvesvar pts: seq[Vec]
for k in0 ..< 8:
let a = k.float * PI / 4 + 0.3 * sin(seed.float)
var side = -0.6if k mod2 == 0: side = 1.0let rr = s * (1 + amp * sin(seed.float * 1.7 + k.float * 2.3) * side)
pts.add vec(rr * cos(a), rr * sin(a))
result = &"M{pts[0].x:.1f} {pts[0].y:.1f}"for k in0 ..< 8:
let (p0, p1, p2, p3) = (pts[(k + 7) mod8], pts[k], pts[(k + 1) mod8], pts[(k + 2) mod8])
let c1 = vec(p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6)
let c2 = vec(p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6)
result.add &"C{c1.x:.1f} {c1.y:.1f} {c2.x:.1f} {c2.y:.1f} {p2.x:.1f} {p2.y:.1f}"result.add "Z"proc shapes(li: int, cls = "tile"): string =
## every shape a skin may pick: square, rounded, round, and a blob of its own for each letter
&"""<g class="{cls} s{li mod 2} g{li}"><rect class="sq" x="-31" y="-31" width="62" height="62" rx="3"/>""" &
"""<rect class="rd" x="-31" y="-31" width="62" height="62" rx="12"/><circle class="ci" r="32"/>""" &
&"""<path class="bl" d="{blobPath(31.5, li * 7 + 3, 0.09)}"/>""" &
"""<circle class="ring" r="21"/></g>"""proc tileSvg(li: int, given: bool): string =
let letter = puz.letters[li]
## all three shapes are drawn; the page's skin (CSS) shows one, and sets the coloursvar cls = "tile"if given: cls = "tile given"result = shapes(li, cls) & """<rect class="sh" x="-25" y="-26" width="50" height="20" rx="8"/>""" &
&"""<text y="13" text-anchor="middle" font-size="36" class="glyph i{li}">{letter}</text>"""proc renderBoard(b: Board): seq[Item] =
var at: array[36, int]
for id in0 ..< 36: at[id] = -1for c, id in b.cells:
if id >= 0: at[id] = c
for id in0 ..< 36:
let c = at[id]
let pos = if c >= 0: cellPos(c) else: stackPos(id div6)
result.add Item(key: &"t{id}", pos: pos, svg: tileSvg(id div6, c >= 0and isGiven(c)))
And here is the entire interaction. Grabbing tile id lists one future per empty cell (a tile never replaces a letter already placed), snaps to the nearest within
half a cell, or else falls back to "the tile went back to its letter". Givens have no spec, so they can't be picked up.
proc place(b: Board, id, cell: int): Board =
result = b
let src = b.cells.find(id)
let other = b.cells[cell]
if src == cell: return
inc result.moves
if src >= 0: result.cells[src] = -1result.cells[cell] = id
if other >= 0and src >= 0: result.cells[src] = other # swapproc home(b: Board, id: int): Board =
result = b
let src = b.cells.find(id)
if src >= 0:
result.cells[src] = -1
inc result.moves
proc isHead(b: Board, at: int): bool =
## the letter on `at` is the last one spelled so far of some word: the head of its snakelet g = b.grid
for clue in puz.clues:
let path = g.trace(clue)
if path.len > 0and path[^1] == at: returntruefalseproc tileSpec(b: Board, key: string): Behavior[Board] =
let id = parseInt(key[1 .. ^1])
let src = b.cells.find(id)
if src >= 0and isGiven(src): returnnilvar futures: seq[Board]
for c in0 ..< 36: # only empty cells: a tile never pushes out a letter already placedifnot isGiven(c) and (b.cells[c] < 0or c == src): futures.add b.place(id, c)
var fallback = b.home(id) # a tile from below goes back to its letterif src >= 0: fallback = b # a tile lifted off the board stays in its cell
closest(futures).within(cell * 0.6).orElse(closest(@[fallback]))
Driving it by hand at build time: grab the first letter's tile and hover between two cells of the top row.
puz = decode(builtin[0][1])
let b0 = puz.boardOf(puz.startGrid)
let free = toSeq(0 ..< 36).filterIt(b0.cells.find(it) < 0)
let key = &"t{free[0]}"let picker = tileSpec(b0, key)(renderBoard, key)
let choice = picker.pick(vec(cellPos(2).x + 30, cellPos(2).y + 4))
echo &"grabbed {key} ({letterOf(free[0])}): {picker.lands.len} futures rendered speculatively"echo &"pointer near r1c3 → shows the board with {letterOf(free[0])} at r1c{choice.state.cells.find(free[0]) + 1}, {int(choice.dist)}px away"let off = picker.pick(vec(300, 640))
echo &"pointer below the board → {off.state.cells.find(free[0])} (-1: back with its letter)"
grabbed t0 (A): 34 futures rendered speculatively
pointer near r1c3 → shows the board with A at r1c3, 30px away
pointer below the board → -1 (-1: back with its letter)
Around the tiles, the backdrop draws the board and red cells for clashes; the overlay draws each word's outline
on top: a ring on its start cell that grows around the letters as they are spelled. Both are plain functions of the board too, so nimib can also draw a
finished board at build time:
var focusClue = -1# the clue chip under the mouse, drawn boldervar wiggleClue = -1# a tapped clue: its ring or outline on the board wiggles for a momentvar maskTag = "wm"# ids of the word-outline masks (the build-time spoiler uses its own)var coachTint: seq[int] # tutorial: cells to tint (the reason for a step)var coachTarget = -1# tutorial: the cell to fill (or empty)var coachStacks: seq[int] # tutorial: letter stacks to make glowproc backdrop(b: Board): string =
let g = b.grid
let bad = g.clashes
result = &"""<rect class="frame" x="{bx - 9}" y="{by - 9}" width="{cell * 6 + 18}" height="{cell * 6 + 18}" rx="14"/>"""for c in0 ..< 36:
let p = cellPos(c)
let (r, col) = (c div6, c mod6)
let base = if (r div2 + col div3) mod2 == 0: "ca"else: "cb"let tinted = if c in coachTint: "tint"else: base
var fill = tinted
if c in bad: fill = tinted & " bad"# while a tile is held, the page's CSS hides the redresult.add &"""<rect class="cell {fill}" x="{p.x - cell/2 + 1.5}" y="{p.y - cell/2 + 1.5}" width="{cell - 3}" height="{cell - 3}" rx="7"/>"""if c in bad:
result.add &"""<rect x="{p.x - cell/2 + 1.5}" y="{p.y - cell/2 + 1.5}" width="{cell - 3}" height="{cell - 3}" rx="7" class="badline"/>"""for i in1 .. 2:
let y = by + i.float * cell * 2result.add &"""<rect class="boxline" x="{bx}" y="{y - 2.5}" width="{cell * 6}" height="5"/>"""result.add &"""<rect class="boxline" x="{bx + cell * 3 - 2.5}" y="{by}" width="5" height="{cell * 6}"/>"""for i in0 .. 5: # row and column numbers, for the tutorial's r3c4result.add &"""<text x="{bx - 22}" y="{cellPos(i * 6).y + 5}" text-anchor="middle" font-size="14" class="mono lbl">r{i + 1}</text>"""result.add &"""<text x="{cellPos(i).x}" y="{by + cell * 6 + 28}" text-anchor="middle" font-size="14" class="mono lbl">c{i + 1}</text>"""for li in coachStacks:
let s = stackPos(li)
result.add &"""<rect class="glow pulse" x="{s.x - 38}" y="{s.y - 38}" width="76" height="76" rx="18"/>"""# a letter with no tiles left shows an empty ring and a tickvar left: array[6, int]
for id in0 ..< 36:
if b.cells.find(id) < 0: inc left[id div6]
for li in0 .. 5:
let s = stackPos(li)
if left[li] == 0:
result.add &"""<rect class="empty" x="{s.x - 31}" y="{s.y - 31}" width="62" height="62" rx="12"/>"""result.add &"""<text class="done" x="{s.x}" y="{s.y + 9}" text-anchor="middle" font-size="26">✓</text>"""proc overlay(b: Board): string =
let g = b.grid
let n = puz.clues.len
for i, clue in puz.clues:
# each word is an outline in its own dark colour: a ring on its start cell that grows around its letterslet path = g.trace(clue)
let ink = &"w{i mod 3}"let t = if focusClue == i: 8.0else: 5.0let rr = cell * 0.47if wiggleClue == i:
var (sx, sy) = (0.0, 0.0)
let cells = if path.len > 0: path else: @[idx(clue.pos)]
for c in cells:
sx += cellPos(c).x
sy += cellPos(c).y
result.add &"""<g class="wig" style="transform-origin:{sx / cells.len.float:.1f}px {sy / cells.len.float:.1f}px">"""if path.len <= 1:
let p = cellPos(idx(clue.pos))
result.add &"""<circle class="wo {ink}" cx="{p.x}" cy="{p.y}" r="{rr + t / 2:.1f}" stroke-width="{t}"/>"""else:
var d = ""for k, c in path:
let p = cellPos(c)
let cmd = if k == 0: "M"else: "L"
d.add &"{cmd}{p.x:.1f},{p.y:.1f} "# round stroke joins round the outer corners; the inner (concave) corners get a fillet each,# so the outline never has a sharp angle: one for the outer edge, a wider one for the inner edgevar fillOut, fillIn = ""let (wOut, wIn, f) = (rr + t, rr, 16.0)
for k in1 .. path.len - 2:
let (p0, p1, p2) = (cellPos(path[k - 1]), cellPos(path[k]), cellPos(path[k + 1]))
let a = vec((p1.x - p0.x) / cell, (p1.y - p0.y) / cell)
let b = vec((p2.x - p1.x) / cell, (p2.y - p1.y) / cell)
if abs(a.x * b.x + a.y * b.y) > 0.5: continue# straight on: no cornerfor (w, r, inner) in [(wOut, f, false), (wIn, f + t, true)]:
let kx = vec(p1.x + w * (b.x - a.x), p1.y + w * (b.y - a.y)) # the concave corner of this edgelet s1 = vec(kx.x - a.x * r, kx.y - a.y * r)
let s2 = vec(kx.x + b.x * r, kx.y + b.y * r)
let piece = &"M{kx.x:.1f},{kx.y:.1f}L{s1.x:.1f},{s1.y:.1f}Q{kx.x:.1f},{kx.y:.1f} {s2.x:.1f},{s2.y:.1f}Z"if inner: fillIn.add piece
else: fillOut.add piece
let m = &"{maskTag}{i}"result.add &"""<mask id="{m}" maskUnits="userSpaceOnUse" x="0" y="0" width="600" height="650">""" &
&"""<path d="{d}" class="wm" stroke="#fff" stroke-width="{2 * wOut:.1f}"/><path d="{fillOut}" fill="#fff"/>""" &
&"""<path d="{d}" class="wm" stroke="#000" stroke-width="{2 * wIn:.1f}"/><path d="{fillIn}" fill="#000"/></mask>""" &
&"""<rect class="wf {ink}" x="0" y="0" width="600" height="650" mask="url(#{m})"/>"""if wiggleClue == i: result.add "</g>"if coachTarget >= 0:
let p = cellPos(coachTarget)
result.add &"""<rect class="aim pulse" x="{p.x - 37}" y="{p.y - 37}" width="74" height="74" rx="12"/>"""proc staticSvg(b: Board): string =
maskTag = "sm"result = """<svg viewBox="40 -10 520 520"><defs><filter id="rough2"><feTurbulence type="fractalNoise" baseFrequency=".035" numOctaves="2" seed="7"/><feDisplacementMap in="SourceGraphic" scale="5"/></filter><filter id="cutout2"><feTurbulence type="fractalNoise" baseFrequency=".012" numOctaves="1" seed="3"/><feDisplacementMap in="SourceGraphic" scale="9"/></filter></defs>""" & backdrop(b)
for it in renderBoard(b):
if b.cells.find(parseInt(it.key[1 .. ^1])) >= 0:
result.add &"""<g transform="translate({it.pos.x},{it.pos.y})">{it.svg}</g>"""result.add overlay(b) & "</svg>"
maskTag = "wm"
let solution = puz.boardOf(puz.solve[0])
let spoiler = "<details class=\"spoiler\"><summary>spoiler: the solved first puzzle, drawn by nimib at build time</summary>" &
solution.staticSvg & "</details>"
spoiler: the solved first puzzle, drawn by nimib at build time
05 · THE RUNTIMEPointer events in, pick calls out
The browser runtime (std/dom) keeps one SVG group per tile key, so a CSS transition slides tiles to their new
places whenever the shown board changes. While a tile is held it floats under the pointer, everything else shows
the picked future, and on release that future is committed.
show the browser runtime (std/dom, ~100 lines)
import std/[dom, tables]
proc svgNode(tag: cstring): Element {.importjs: "document.createElementNS('http://www.w3.org/2000/svg', #)".}
proc capture(e: Element, ev: Event) {.importjs: "#.setPointerCapture(#.pointerId)".}
proc keyAt(ev: Event): cstring {.importjs: "(#.target.closest('[data-key]')?.dataset.key ?? null)".}
proc setStyle(e: Element, k, v: cstring) {.importjs: "#.style.setProperty(#, #)".}
proc checked(e: Element): bool {.importjs: "#.checked".}
proc clientXY(ev: Event): Vec {.importjs: "(e => ({x: e.clientX, y: e.clientY}))(#)".}
proc setTimer(cb: proc (), ms: int): int {.importjs: "setTimeout(#, #)".}
proc clearTimer(t: int) {.importjs: "clearTimeout(#)".}
proc buzz() {.importjs: "(navigator.vibrate && navigator.vibrate(12))".}
proc rectOf(e: Element): (float, float, float, float) {.importjs:
"(() => { const r = #.getBoundingClientRect(); return {Field0: r.left, Field1: r.top, Field2: r.width, Field3: r.height}; })()".}
typeStage[S] = refobject
state: S
render: Render[S]
spec: proc (s: S, key: string): Behavior[S]
backdrop, overlay: proc (s: S): string
svg, bg, fg, top, ghosts: Element
nodes: Table[string, Element]
looks: Table[string, string]
dragKey: string
grab, at: Vec
picker: Picker[S]
choice: Choice[S]
showGhosts: bool
onCommit: proc ()
onMove: proc () # after every pointer move and drop: effects drawn outside the view
redirect: proc (s: S, key: string): string# grabbing `key` may lift another item instead ("" = nothing)
origin: Vec# where the held item was grabbed
redirected: bool
holdMs: int# how long a press on a redirected item waits before lifting the item itselfproc tr(p: Vec): cstring = cstring(&"translate({p.x:.2f}px, {p.y:.2f}px)")
proc drawGhosts[S](st: Stage[S]) =
var g = ""if st.dragKey.len > 0:
if st.showGhosts:
for l in st.picker.lands:
if l.x == l.x: g.add &"""<circle class="land" cx="{l.x}" cy="{l.y}" r="6"/>"""if st.choice.ok:
let c = st.choice.land
g.add &"""<rect class="target" x="{c.x - 36}" y="{c.y - 36}" width="72" height="72" rx="13"/>"""if st.showGhosts:
g.add &"""<line class="tether" x1="{st.at.x}" y1="{st.at.y}" x2="{c.x}" y2="{c.y}"/>"""
st.ghosts.innerHTML = cstring(g)
proc draw[S](st: Stage[S], s: S) =
st.bg.innerHTML = cstring(st.backdrop(s))
for it in st.render(s):
let held = it.key == st.dragKey
var n = st.nodes.getOrDefault(it.key)
if n.isNil:
n = svgNode("g")
n.setAttribute("data-key", cstring(it.key))
n.setStyle("transform", tr(it.pos))
st.fg.appendChild(n)
st.nodes[it.key] = n
if st.looks.getOrDefault(it.key) != it.svg:
n.innerHTML = cstring(it.svg)
st.looks[it.key] = it.svg
let grab = if st.spec(s, it.key).isNil: ""else: " grab"let lifted = if held: " lifted"else: ""let pos = if held: st.at else: it.pos
n.setAttribute("class", cstring("item" & grab & lifted))
n.setStyle("transform", tr(pos))
st.top.innerHTML = cstring(st.overlay(s))
st.drawGhosts()
proc move[S](st: Stage[S], p: Vec) =
st.at = p - st.grab
st.choice = st.picker.pick(st.at)
let shown = if st.choice.ok: st.choice.state else: st.state
st.draw(shown)
ifnot st.onMove.isNil: st.onMove()
proc start[S](st: Stage[S], grabbed: string, p: Vec, redirect = true) =
var key = grabbed
if redirect andnot st.redirect.isNil: key = st.redirect(st.state, grabbed)
if key.len == 0: returnlet behavior = st.spec(st.state, key)
if behavior.isNil: return
st.dragKey = key
st.redirected = key != grabbed
st.origin = st.render.locate(st.state, grabbed)
st.grab = p - st.origin # a redirected item starts where the grabbed one is
st.picker = behavior(st.render, key) # speculative renders happen here
st.fg.appendChild(st.nodes[key]) # float above the others
st.svg.classList.add("dragging")
st.move(p)
proc finish[S](st: Stage[S]) =
if st.dragKey.len == 0: returnlet key = st.dragKey
let was = st.render.locate(st.state, key)
if st.choice.ok: st.state = st.choice.state
let now = st.render.locate(st.state, key)
st.dragKey = ""
st.svg.classList.remove("dragging")
let n = st.nodes[key]
let snap = st.redirected and now.x == was.x and now.y == was.y
if snap: n.setStyle("transition", "none") # a copy let go nowhere melts back into the grabbed one: no glide
st.draw(st.state) # the held tile glides homeif snap:
discard rectOf(n)
n.setStyle("transition", "")
ifnot st.onMove.isNil: st.onMove()
ifnot st.onCommit.isNil: st.onCommit()
proc setState[S](st: Stage[S], s: S) =
st.state = s
st.draw(s)
ifnot st.onCommit.isNil: st.onCommit()
proc mount[S](host: Element, w, h: float, init: S, render: Render[S],
spec: proc (s: S, key: string): Behavior[S],
backdrop, overlay: proc (s: S): string, x0 = 0.0): Stage[S] =
host.innerHTML = cstring(&"""<svg class="d2" viewBox="{x0} 0 {w} {h}"><defs><filter id="rough"><feTurbulence type="fractalNoise" baseFrequency=".035" numOctaves="2" seed="7"/><feDisplacementMap in="SourceGraphic" scale="5"/></filter><filter id="cutout"><feTurbulence type="fractalNoise" baseFrequency=".012" numOctaves="1" seed="3"/><feDisplacementMap in="SourceGraphic" scale="9"/></filter></defs><g class="bg"></g>""" &
"""<g class="fg"></g><g class="overlay"></g><g class="ghosts"></g></svg>""" &
"""<label class="ghost-toggle"><input type="checkbox"> <span data-it="mostra il meccanismo">show machinery</span></label>""")
let st = Stage[S](state: init, render: render, spec: spec, backdrop: backdrop, overlay: overlay, holdMs: 320)
st.svg = host.querySelector("svg")
st.bg = host.querySelector(".bg")
st.fg = host.querySelector(".fg")
st.top = host.querySelector(".overlay")
st.ghosts = host.querySelector(".ghosts")
let toLocal = proc (ev: Event): Vec =
let (l, t, rw, rh) = rectOf(st.svg)
let c = clientXY(ev)
vec(x0 + (c.x - l) * w / rw, (c.y - t) * h / rh)
# an item that a grab redirects (a letter on the board) waits a moment: moving at once pulls the redirected one# (a copy); holding still lifts the item itself, to move itvar pendKey = ""var pendAt = vec(0, 0)
var pendTimer = 0
st.svg.addEventListener("pointerdown", proc (ev: Event) =
let k = keyAt(ev)
if k.isNil: return
ev.preventDefault()
capture(st.svg, ev)
let p = toLocal(ev)
var r = $k
ifnot st.redirect.isNil: r = st.redirect(st.state, $k)
if r.len == 0: return# "": the page handles this grab itselfif r == $k or st.spec(st.state, $k).isNil:
st.start($k, p)
return
pendKey = $k
pendAt = p
pendTimer = setTimer(proc () =
if pendKey.len > 0:
let key = pendKey
pendKey = ""
buzz()
st.start(key, pendAt, redirect = false), st.holdMs))
st.svg.addEventListener("pointermove", proc (ev: Event) =
let p = toLocal(ev)
if pendKey.len > 0and norm(p - pendAt) > 6:
clearTimer(pendTimer)
let key = pendKey
pendKey = ""
st.start(key, pendAt)
if st.dragKey.len > 0: st.move(p))
let up = proc (ev: Event) =
if pendKey.len > 0:
clearTimer(pendTimer)
pendKey = ""
st.finish()
st.svg.addEventListener("pointerup", up)
st.svg.addEventListener("pointercancel", up)
let box = host.querySelector(".ghost-toggle input")
box.addEventListener("change", proc (ev: Event) =
st.showGhosts = box.checked
st.drawGhosts())
st.draw(init)
st
That's the game. Puzzles live in data/ as puzzlepaw JSON,
and any puzzle can be played from a link such as ?p=AILPST_51S.23L.04P_00ITALIA.20PASTA.40ALPI
(the 🔗 button copies it). Progress is kept in your browser, per puzzle.
The game speaks English and Italian (EN / IT at the top, or ?lang=it), and ?tutorial opens the 🎓 tutorial directly.
Built with nimib. Hit show source below for the whole notebook.
import std/[json, os, algorithm, sequtils]
import nimib
import style, splash
nbInit
# write the page at the repo root (served by GitHub Pages), wherever the build is run from
nb.doc.filename = currentSourcePath().parentDir.parentDir / "index.html"
nb.doc.context["title"] = %"Parloku"
nb.doc.context["stylesheet"] = %pageStyle
nb.doc.context["highlight"] = %""
nb.doc.context["disableHighlightJs"] = %truefunc pageHeader(blk: JsonNode, nb: Nb): string =
""# the title says it all: no header line above the game
nb.backend.partials["header"] = pageHeader
var skinButtons = ""for (id, label, flag) in skinList:
skinButtons.add "<button data-skin=\"" & id & "\"><span class=\"dots\"><i></i><i></i><i></i></span>" & label & " " & flag & "</button>"
nbRawHtml: """
<div class="play" id="play">
<h1><span class="tile">P</span>arloku</h1>
<p class="lede" data-it="Un mini sudoku fatto di lettere, con delle parole nascoste.">A mini sudoku spelled in letters, with words hidden in it.</p>
<div class="game">
<div class="picker"><button class="btn tut" id="tutorial" data-it="🎓<span class='tx'> tutorial</span>" aria-label="tutorial">🎓<span class="tx"> tutorial</span></button><span id="picker"></span>
<div class="langs" id="langs"><button class="btn" data-lang="en">EN</button><button class="btn" data-lang="it">IT</button></div>
</div>
<div class="coach" id="coach" hidden>
<div class="hd" id="coach-hd"></div>
<p id="coach-text"></p>
<div class="acts" id="coach-acts"></div>
</div>
<div class="clues" id="clues"></div>
<div class="stage" id="board"></div>
<div class="status">
<span class="acts">
<button class="btn" id="undo" data-it="↶<span class='tx'> annulla</span>" aria-label="undo" disabled>↶<span class="tx"> undo</span></button>
<button class="btn" id="hint" data-it="💡<span class='tx'> aiuto</span>" aria-label="hint">💡<span class="tx"> hint</span></button>
<button class="btn" id="reset" data-it="↺<span class='tx'> ricomincia</span>" aria-label="reset">↺<span class="tx"> reset</span></button>
<button class="btn" id="share" data-it="🔗<span class='tx'> link</span>" aria-label="link">🔗<span class="tx"> link</span></button>
<button class="btn" id="more" data-it="📖<span class='tx'> altro</span>" aria-label="more" hidden>📖<span class="tx"> more</span></button>
</span>
</div>
</div>
<div class="rules">
<div data-it="<b>01 · SUDOKU</b>Ognuna delle sei lettere compare una volta in ogni riga, in ogni colonna e in ogni riquadro 2×3."><b>01 · SUDOKU</b>Each of the six letters appears once in every row, every column and every 2×3 box.</div>
<div data-it="<b>02 · PAROLE</b>Ogni parola parte dalla casella con il suo cerchio colorato e si scrive un passo alla volta: in su, in giù, a destra o a sinistra. Il contorno cresce con le lettere."><b>02 · WORDS</b>Each word starts in the cell ringed in its colour and is spelled one step at a time, up, down, left or right. Its outline grows with the letters.</div>
<div data-it="<b>03 · TESSERE</b>Sotto la griglia c'è una tessera per lettera. Tirala e si divide: la copia va su una casella. Riporta una tessera alla sua lettera e si riuniscono."><b>03 · TILES</b>Below the board is one tile per letter. Pull it and it divides: the copy goes on a cell. Bring a tile back to its letter and they merge.</div>
</div>
<div class="skins" id="skins">""" & skinButtons & """</div>
</div>
"""
nbText: """
Parloku is [Pietro](https://github.com/pietroppeter)'s word-flavoured
[mini sudoku](https://github.com/pietroppeter/puzzlepaw#parloku): a 6×6 grid, six letters instead of six digits,
and a few words threaded through the grid as extra clues. The puzzles come from
[puzzlepaw](https://github.com/pietroppeter/puzzlepaw), where they are crafted in a terminal UI.
This page is a [nimib](https://github.com/pietroppeter/nimib) notebook. Everything below is the Nim source
of the game you just played. Most blocks run **twice**: natively while nimib builds the page (that is where the
printed outputs come from) and in your browser, compiled with `nim js`. The dragging uses a tiny port of
**Declarative Dragging** ([Horowitz, Blinn & Heer, UIST 2026](https://declarative-dragging.github.io/)):
nobody wrote a drop handler, the code only lists the boards a drag can reach.
"""
nbRawHtml: """<h2><span class="num">01 · THE MODEL</span>Six letters, thirty-six tiles</h2>"""
nbText: """
A puzzle is six letters, a few given letters and the word clues. A *board* says which tile sits in each cell.
There are 36 tiles, six copies of each letter, so tile `id` is copy `id mod 6` of letter `id div 6`.
A given letter is just a tile that is glued down.
Puzzles travel as a short code that fits in a URL, so a future builder (or a daily puzzle) only has to hand out a link.
The code is `LETTERS_GIVENS_WORDS` (the format puzzlepaw uses too): givens are row, column, letter and words are
row, column, word, both 0-based and separated by dots.
"""
nbCodeAnd(nbJsFromCode):
import std/[math, strformat, strutils, sequtils]
typeCoord = tuple[row, col: int]
Given = object
pos: Coord
letter: charClue = object
pos: Coord
word: stringPuzzle = object
letters: string# the six letters
givens: seq[Given]
clues: seq[Clue]
Grid = array[36, char] # '.' for an empty cellBoard = object
cells: array[36, int] # tile id in each cell, -1 when empty
moves: intfunc idx(r, c: int): int = r * 6 + c
func idx(p: Coord): int = idx(p.row, p.col)
proc encode(p: Puzzle): string =
## LETTERS_GIVENS_WORDS, the code format shared with puzzlepaw
p.letters & "_" & p.givens.mapIt(&"{it.pos.row}{it.pos.col}{it.letter}").join(".") &
"_" & p.clues.mapIt(&"{it.pos.row}{it.pos.col}{it.word}").join(".")
proc decode(code: string): Puzzle =
var c = code.strip(chars = {'?', '#', ' '})for part in c.split('&'): # a whole query string: take its p= value, wherever it isif part.startsWith("p="): c = part[2 .. ^1]
let parts = c.toUpperAscii.split('_')if parts.len != 3: returnresult.letters = parts[0]
for kind in1 .. 2:
for item in parts[kind].split('.'):if item.len >= 3and item[0] in'0'..'5'and item[1] in'0'..'5':
let pos = (row: ord(item[0]) - ord('0'), col: ord(item[1]) - ord('0'))
if kind == 1: result.givens.add Given(pos: pos, letter: item[2])
else: result.clues.add Clue(pos: pos, word: item[2 .. ^1])
proc valid(p: Puzzle): bool =
p.letters.len == 6and p.letters.deduplicate.len == 6and
p.givens.allIt(it.letter in p.letters and it.pos.row in0..5and it.pos.col in0..5) and
p.clues.allIt(it.word.allIt(it in p.letters) and it.pos.row in0..5and it.pos.col in0..5)
var puz: Puzzle# the puzzle being playedproc letterOf(id: int): char = puz.letters[id div6]
proc isGiven(cell: int): bool = puz.givens.anyIt(idx(it.pos) == cell)
proc grid(b: Board): Grid =
for c, id in b.cells:
result[c] = '.'if id >= 0: result[c] = letterOf(id)
proc boardOf(p: Puzzle, g: Grid): Board =
## a board holding the letters of `g`, using the lowest free copy of each lettervar used: array[6, int]
for c in0 ..< 36:
result.cells[c] = -1let li = p.letters.find(g[c])
if li >= 0and used[li] < 6:
result.cells[c] = li * 6 + used[li]
inc used[li]
proc startGrid(p: Puzzle): Grid =
for c inresult.mitems: c = '.'for gv in p.givens: result[idx(gv.pos)] = gv.letter
nbText: "The puzzles are the JSON files crafted with puzzlepaw, read at build time and turned into codes:"
nbCode:
var builtin: seq[(string, string)] # (title, code)var answerKeys: seq[Grid] # the solutions stored in the JSON filesvar files = toSeq(walkFiles(currentSourcePath().parentDir.parentDir / "data" / "puzzle*.json"))
files.sort()
for f in files:
let all = parseFile(f)
let j = all["problem"]
var p = Puzzle(letters: j["letters"].mapIt(it.getStr).join)
for h in j["letterHints"]:
p.givens.add Given(pos: (h["pos"][0].getInt, h["pos"][1].getInt), letter: h["letter"].getStr[0])
for h in j["wordHints"]:
p.clues.add Clue(pos: (h["pos"][0].getInt, h["pos"][1].getInt), word: h["word"].getStr)
builtin.add (p.clues.mapIt(it.word).join(" · "), p.encode)
var key: Gridfor r, row in all["solution"].getElems:
for c, ch in row.getElems: key[idx(r, c)] = ch.getStr[0]
answerKeys.add key
for (title, code) in builtin:
echo title, "\n ?p=", code, " valid: ", decode(code).valid
nbRawHtml: """<h2><span class="num">02 · THE RULES, AS CODE</span>Checking, tracing, solving</h2>"""
nbText: """
Three functions carry the rules. `clashes` finds repeated letters in a row, column or box (they glow red on the board).
`trace` walks a clue through the grid with a depth-first search and returns the longest prefix it can spell, so
the clue chips light up letter by letter and the path is drawn over the tiles.
With `wild = true` an empty cell matches any letter, which is what a solver needs to prune early.
"""
nbCodeAnd(nbJsFromCode):
proc makeUnits(): seq[seq[int]] =
for r in0 .. 5: result.add toSeq(0 .. 5).mapIt(idx(r, it))
for c in0 .. 5: result.add toSeq(0 .. 5).mapIt(idx(it, c))
for b in0 .. 5:
var u: seq[int]
for r in0 .. 1:
for c in0 .. 2: u.add idx((b div2) * 2 + r, (b mod2) * 3 + c)
result.add u
let units = makeUnits()
proc peers(cell: int): seq[int] =
for u in units:
if cell in u:
for j in u:
if j != cell and j notinresult: result.add j
let peersOf = toSeq(0 ..< 36).mapIt(peers(it))
proc clashes(g: Grid): seq[int] =
for c in0 ..< 36:
if g[c] != '.'and peersOf[c].anyIt(g[it] == g[c]): result.add c
proc trace(g: Grid, clue: Clue, wild = false, revisit = false): seq[int] =
## longest prefix of the word that can be walked from its start cell.## The rules let a path come back to a cell it already used (`revisit`, for the solver); the board's## outlines don't, or G-I-O-C would read as GIOCO by stepping back onto the Oifnot revisit:
var best, path: seq[int]
let word = clue.word
proc walk(cell, i: int) =
if best.len == word.len or cell in path: returnlet ok = g[cell] == word[i] or (wild and g[cell] == '.')
ifnot ok: return
path.add cell
if path.len > best.len: best = path
if i + 1 < word.len:
let (r, c) = (cell div6, cell mod6)
if r > 0: walk(cell - 6, i + 1)
if r < 5: walk(cell + 6, i + 1)
if c > 0: walk(cell - 1, i + 1)
if c < 5: walk(cell + 1, i + 1)
discard path.pop
walk(idx(clue.pos), 0)
return best
var best, path: seq[int]
let word = clue.word
proc walk(cell, i: int) =
if best.len == word.len: returnlet ok = g[cell] == word[i] or (wild and g[cell] == '.')
ifnot ok: return
path.add cell
if path.len > best.len: best = path
if i + 1 < word.len:
let (r, c) = (cell div6, cell mod6)
if r > 0: walk(cell - 6, i + 1)
if r < 5: walk(cell + 6, i + 1)
if c > 0: walk(cell - 1, i + 1)
if c < 5: walk(cell + 1, i + 1)
discard path.pop
walk(idx(clue.pos), 0)
best
proc solved(p: Puzzle, g: Grid): bool =
'.'notin g and g.clashes.len == 0and p.clues.allIt(g.trace(it, revisit = true).len == it.word.len)
proc solve(p: Puzzle, limit = 2): seq[Grid] =
## backtracking with "fewest candidates first", pruning on the clues;## stops after `limit` solutions (2 is enough to tell unique from ambiguous)var g = startGrid(p)
var sols: seq[Grid]
proc fits(g: Grid, cell: int, ch: char): bool = peersOf[cell].allIt(g[it] != ch)
proc cluesOk(g: Grid): bool = p.clues.allIt(g.trace(it, wild = true, revisit = true).len == it.word.len)
proc go(g: varGrid) =
if sols.len >= limit: returnvar best = -1var bestN = 99for c in0 ..< 36:
if g[c] == '.':
let n = p.letters.countIt(fits(g, c, it))
if n < bestN: (best, bestN) = (c, n)
if best < 0:
if solved(p, g): sols.add g
returnfor ch in p.letters:
if fits(g, best, ch):
g[best] = ch
if cluesOk(g): go(g)
g[best] = '.'if g.clashes.len == 0and cluesOk(g): go(g)
sols
nbText: "At build time the solver checks each puzzle has exactly one solution, and traces the clues through it:"
nbCode:
for i, (title, code) in builtin:
let p = decode(code)
let sols = p.solve
let verdict = if sols.len == 1: " (unique)"else: "s found, stopped"let key = if sols[0] == answerKeys[i]: "matches"else: "DIFFERS FROM"echo title, ": ", sols.len, " solution", verdict, ", ", key, " the answer key in data/"for clue in p.clues:
let path = sols[0].trace(clue).mapIt(&"r{it div 6 + 1}c{it mod 6 + 1}")
echo &" {clue.word:<7}", path.join(" → ")
nbText: """
The solver is also the seed of a puzzle *builder*: pick letters, givens and words, and it tells you whether the
setup has zero, one or many solutions. In the game it powers the 💡 button, which glides one correct tile into place.
"""
nbRawHtml: """<h2><span class="num">03 · A LOGICAL PATH</span>Solving like a person</h2>"""
nbText: """
Backtracking finds the answer but can't explain it. The 🎓 tutorial needs moves a person can see, so here is
Pietro's method as code. First put the first letter of every word in its ringed cell. Then follow each word
letter by letter: the next letter sits next to the previous one, has to fit the sudoku, and has to leave a way
to finish the word (`spellings` lists every walk that still could). When only one cell qualifies, the letter is forced.
When no word moves, plain sudoku singles take over: a cell with one candidate left, or a letter with one place left in a row,
column or box. The tutorial asks for the next step after every drop, so it copes with any order and even with mistakes.
"""
nbCodeAnd(nbJsFromCode):
typeStepKind = enum skStart, skWord, skNaked, skHidden, skFix, skReveal
Step = object
kind: StepKind
cell: int# where the tile goes (-1: no step)
letter: char
clue: int# start and word steps: which clue
unit: int# hidden singles: which row, column or box
path: seq[int] # word steps: the word so far, ending in `cell`
dead: seq[int] # word steps: neighbours that fit the sudoku but can't finish the wordvar lang = "en"# "en" or "it", set by the pageprocL(en, it: string): string = (if lang == "it": it else: en)
proc cands(p: Puzzle, g: Grid, c: int): string =
if g[c] != '.': return $g[c]
for ch in p.letters:
if peersOf[c].allIt(g[it] != ch): result.add ch
proc neighbours(at: int): seq[int] =
for nb in [at - 6, at + 6, at - 1, at + 1]:
if nb >= 0and nb < 36and (abs(nb - at) == 6or nb div6 == at div6): result.add nb
proc spellings(p: Puzzle, g: Grid, clue: Clue): seq[seq[int]] =
## every walk that could still spell the clue: each cell holds its letter or could take it,## and the walk never puts the same letter twice in a row, column or boxvar found: seq[seq[int]]
var cur: seq[int]
let word = clue.word
proc walk(cell, i: int) =
let ch = word[i]
ifnot (g[cell] == ch or (g[cell] == '.'and ch in cands(p, g, cell))): returnfor k, prev in cur:
if prev == cell and word[k] != ch: returnif prev != cell and word[k] == ch and prev in peersOf[cell]: return
cur.add cell
if cur.len == word.len: found.add cur
else:
for nb in neighbours(cell): walk(nb, i + 1)
discard cur.pop
walk(idx(clue.pos), 0)
found
proc wordStep(p: Puzzle, g: Grid): Step =
result.cell = -1for i, clue in p.clues:
let start = idx(clue.pos)
if g[start] == '.': returnStep(kind: skStart, cell: start, letter: clue.word[0], clue: i, path: @[start])
for i, clue in p.clues:
let sp = spellings(p, g, clue)
var path = @[idx(clue.pos)]
for k in1 ..< clue.word.len:
let ch = clue.word[k]
let at = path[^1]
let fits = neighbours(at).filterIt(g[it] == ch or (g[it] == '.'and ch in cands(p, g, it)))
let next = sp.mapIt(it[k]).deduplicate
if next.len != 1: break
path.add next[0]
if g[next[0]] == '.':
returnStep(kind: skWord, cell: next[0], letter: ch, clue: i, path: path,
dead: fits.filterIt(it != next[0]))
proc singleStep(p: Puzzle, g: Grid): Step =
result.cell = -1for c in0 ..< 36:
if g[c] == '.':
let cs = cands(p, g, c)
if cs.len == 1: returnStep(kind: skNaked, cell: c, letter: cs[0])
for u, cells in units:
for ch in p.letters:
if cells.anyIt(g[it] == ch): continuelet spots = cells.filterIt(g[it] == '.'and ch in cands(p, g, it))
if spots.len == 1: returnStep(kind: skHidden, cell: spots[0], letter: ch, unit: u)
proc logicalStep(p: Puzzle, g: Grid, solution: Grid): Step =
## the next move a person would make; a wrong tile is pointed out firstfor c in0 ..< 36:
if g[c] != '.'and solution[c] != '.'and g[c] != solution[c]:
returnStep(kind: skFix, cell: c, letter: g[c])
result = wordStep(p, g)
ifresult.cell < 0: result = singleStep(p, g)
ifresult.cell < 0:
for c in0 ..< 36:
if g[c] == '.'and solution[c] != '.': returnStep(kind: skReveal, cell: c, letter: solution[c])
proc rc(c: int): string = &"r{c div 6 + 1}c{c mod 6 + 1}"proc nth(k: int): string =
const en = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth"]
const it = ["prima", "seconda", "terza", "quarta", "quinta", "sesta", "settima", "ottava"]
if k < 1or k > 8: return $k
L(en[k - 1], it[k - 1])
proc inUnit(u: int): string =
## "in row 5" / "nella riga 5"if u < 6: L(&"in row {u + 1}", &"nella riga {u + 1}")
elif u < 12: L(&"in column {u - 5}", &"nella colonna {u - 5}")
else: L(&"in box {u - 11}", &"nel riquadro {u - 11}")
proc blockUnit(g: Grid, cell: int, ch: char): int =
## why `ch` can't go in `cell`: -1 the cell is taken, else the unit that already has `ch`if g[cell] != '.': return -1for u, cells in units:
if cell in cells and cells.anyIt(g[it] == ch): return u
-2proc an(ch: char): string = (if ch in"AEFHILMNORSX": "an"else: "a")
proc blocked(g: Grid, cells: seq[int], ch: char): seq[string] =
## "r3c1 and r5c1 are in column 1, which already has an A"var keys: seq[int]
for c in cells:
let k = blockUnit(g, c, ch)
if k notin keys: keys.add k
for k in keys:
let group = cells.filterIt(blockUnit(g, it, ch) == k)
let names = group.mapIt(rc(it)).join(L(" and ", " e "))
let isEn = if group.len > 1: "are"else: "is"let isIt = if group.len > 1: "sono"else: "è"let takenIt = if group.len > 1: "occupate"else: "occupata"if k == -1:
result.add L(&"{names} {isEn} taken", &"{names} {isIt} {takenIt}")
elif k >= 0:
result.add L(&"{names} {isEn} {inUnit(k)}, which already has {an(ch)} {ch}",
&"{names} {isIt} {inUnit(k)}, che ha già una {ch}")
proc explain(p: Puzzle, g: Grid, s: Step): string =
let w = if s.clue < p.clues.len: p.clues[s.clue].word else: ""case s.kind
of skStart:
L(&"<b>{w}</b> starts in cell {s.clue + 1}, so {rc(s.cell)} is <b>{s.letter}</b>.",
&"<b>{w}</b> parte dalla casella {s.clue + 1}, quindi {rc(s.cell)} è <b>{s.letter}</b>.")
of skWord:
let k = s.path.len
let prev = s.path[^2]
var notes: seq[string]
var others: seq[int]
for o in neighbours(prev):
if o == s.cell or o in s.path: continueif o notin s.dead:
others.add o
continuevar g2 = g
g2[o] = s.letter
let nxt = w[k]
let room = neighbours(o).filterIt(g2[it] == nxt or (g2[it] == '.'and nxt in cands(p, g2, it)))
if room.len == 0:
notes.add L(&"{rc(o)} fits {s.letter}, but then the next letter, {nxt}, would have no room",
&"in {rc(o)} la {s.letter} ci starebbe, ma poi la lettera dopo, {nxt}, non avrebbe posto")
else:
notes.add L(&"{rc(o)} fits {s.letter}, but from there {w} can't be finished",
&"in {rc(o)} la {s.letter} ci starebbe, ma da lì {w} non si può completare")
notes = blocked(g, others, s.letter) & notes
L(&"The {nth(k)} letter of <b>{w}</b>, <b>{s.letter}</b>, sits next to {rc(prev)}. ",
&"La {nth(k)} lettera di <b>{w}</b>, <b>{s.letter}</b>, sta accanto a {rc(prev)}. ") &
notes.join("; ") & L(&". So {rc(s.cell)} is <b>{s.letter}</b>.", &". Quindi {rc(s.cell)} è <b>{s.letter}</b>.")
of skNaked:
let seen = p.letters.filterIt(it != s.letter).join(" ")
L(&"{rc(s.cell)} already sees {seen} in its row, column and box: only <b>{s.letter}</b> fits.",
&"{rc(s.cell)} vede già {seen} nella sua riga, colonna e riquadro: ci sta solo la <b>{s.letter}</b>.")
of skHidden:
L(&"{inUnit(s.unit).capitalizeAscii}, <b>{s.letter}</b> fits only in {rc(s.cell)}.",
&"{inUnit(s.unit).capitalizeAscii}, la <b>{s.letter}</b> può andare solo in {rc(s.cell)}.")
of skFix:
L(&"The {s.letter} in {rc(s.cell)} is not right. Drag it back to its letter below the board.",
&"La {s.letter} in {rc(s.cell)} non è giusta. Riportala alla sua lettera sotto la griglia.")
of skReveal:
L(&"This one is tricky: {rc(s.cell)} is <b>{s.letter}</b>.", &"Questa è difficile: {rc(s.cell)} è <b>{s.letter}</b>.")
nbText: "The path it finds for the first puzzle, which the tutorial walks through:"
nbCode:
block:
let p = decode(builtin[0][1])
let sol = p.solve[0]
var g = startGrid(p)
var n = 0while'.'in g:
let s = logicalStep(p, g, sol)
inc n
echo &"{n:>2}. ", explain(p, g, s).replace("<b>", "").replace("</b>", "")
g[s.cell] = s.letter
nbRawHtml: """<h2><span class="num">04 · THE DRAG SPEC</span>Listing futures instead of handling drops</h2>"""
nbText: """
The drag engine is the one from [Declarative Dragging, in Nim](https://pietroppeter.github.io/claude-artifacts/declarative-dragging-nim/):
a view is a pure function from model to keyed, positioned items, and a *behaviour* turns a list of reachable
models into a picker that chooses the one landing the held item closest to the pointer.
Tick the box and drag a tile on the board above: the dashed squares are every board this drag can reach.
"""
nbRawHtml: """<p class="machinery-slot" id="machinery-slot"></p>"""
nbRawHtml: "<details><summary>show the engine (closest · within · orElse, ~60 lines)</summary>"
nbCodeAnd(nbJsFromCode):
typeVec = object
x, y: floatItem = object## one keyed element of a rendered view
key: string## identity across states (what your eye tracks)
pos: Vec## where it sits in this state
svg: string## what it looks like, drawn around (0, 0)Render[S] = proc (s: S): seq[Item]
Choice[S] = object
ok: bool
state: S
land: Vec## where the dragged element lands in `state`
dist: floatPicker[S] = object## a behaviour, instantiated at drag start
lands: seq[Vec] ## speculative landing spots
pick: proc (p: Vec): Choice[S]
Behavior[S] = proc (render: Render[S], key: string): Picker[S]
func vec(x, y: float): Vec = Vec(x: x, y: y)
func `+`(a, b: Vec): Vec = vec(a.x + b.x, a.y + b.y)
func `-`(a, b: Vec): Vec = vec(a.x - b.x, a.y - b.y)
func norm(a: Vec): float = hypot(a.x, a.y)
proc locate[S](render: Render[S], s: S, key: string): Vec =
## speculative rendering: draw `s` without painting it, report where `key` landsfor it in render(s):
if it.key == key: return it.pos
vec(NaN, NaN)
proc closest[S](states: seq[S]): Behavior[S] =
result = proc (render: Render[S], key: string): Picker[S] =
var lands: seq[Vec]
for s in states: lands.add render.locate(s, key)
result.lands = lands
result.pick = proc (p: Vec): Choice[S] =
result.dist = Inffor i, s in states:
let d = norm(lands[i] - p)
if d < result.dist:
result = Choice[S](ok: true, state: s, land: lands[i], dist: d)
proc within[S](b: Behavior[S], radius: float): Behavior[S] =
result = proc (render: Render[S], key: string): Picker[S] =
let inner = b(render, key)
result.lands = inner.lands
result.pick = proc (p: Vec): Choice[S] =
result = inner.pick(p)
ifresult.dist > radius: result.ok = falseproc orElse[S](a, b: Behavior[S]): Behavior[S] =
result = proc (render: Render[S], key: string): Picker[S] =
let pa = a(render, key)
let pb = b(render, key)
result.lands = pa.lands & pb.lands
result.pick = proc (p: Vec): Choice[S] =
result = pa.pick(p)
ifnotresult.ok: result = pb.pick(p)
nbRawHtml: "</details>"
nbText: """
The view puts each tile either in its cell or on its letter's spot below the board. All the tiles still to place sit
exactly on top of each other, so the player sees one tile per letter: pulling the top one off looks like the tile
dividing, and bringing one back looks like two tiles merging.
Six colours, one per letter. The view only names classes (`g0`…`g5`, `ca`, `boxline`, `wp c1`…), so the page's CSS *skin* picks the palette, the fonts and even the tile shape: square, rounded or round.
"""
nbCodeAnd(nbJsFromCode):
## colours live in the page's CSS skins: --g0..--g5 tile colours, --i0..--i5 their letters, --c0..--c4 the cluesconst cell = 78.0const bx = 66.0const by = 16.0proc cellPos(c: int): Vec = vec(bx + float(c mod6) * cell + cell / 2, by + float(c div6) * cell + cell / 2)
proc stackPos(li: int): Vec = vec(150 + float(li mod3) * 150, 546 + float(li div3) * 74) # two rows of three under the boardproc blobPath(s: float, seed: int, amp: float): string =
## a soft closed blob around the origin: 8 points with varied radii, joined by Catmull-Rom curvesvar pts: seq[Vec]
for k in0 ..< 8:
let a = k.float * PI / 4 + 0.3 * sin(seed.float)
var side = -0.6if k mod2 == 0: side = 1.0let rr = s * (1 + amp * sin(seed.float * 1.7 + k.float * 2.3) * side)
pts.add vec(rr * cos(a), rr * sin(a))
result = &"M{pts[0].x:.1f} {pts[0].y:.1f}"for k in0 ..< 8:
let (p0, p1, p2, p3) = (pts[(k + 7) mod8], pts[k], pts[(k + 1) mod8], pts[(k + 2) mod8])
let c1 = vec(p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6)
let c2 = vec(p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6)
result.add &"C{c1.x:.1f} {c1.y:.1f} {c2.x:.1f} {c2.y:.1f} {p2.x:.1f} {p2.y:.1f}"result.add "Z"proc shapes(li: int, cls = "tile"): string =
## every shape a skin may pick: square, rounded, round, and a blob of its own for each letter
&"""<g class="{cls} s{li mod 2} g{li}"><rect class="sq" x="-31" y="-31" width="62" height="62" rx="3"/>""" &
"""<rect class="rd" x="-31" y="-31" width="62" height="62" rx="12"/><circle class="ci" r="32"/>""" &
&"""<path class="bl" d="{blobPath(31.5, li * 7 + 3, 0.09)}"/>""" &
"""<circle class="ring" r="21"/></g>"""proc tileSvg(li: int, given: bool): string =
let letter = puz.letters[li]
## all three shapes are drawn; the page's skin (CSS) shows one, and sets the coloursvar cls = "tile"if given: cls = "tile given"result = shapes(li, cls) & """<rect class="sh" x="-25" y="-26" width="50" height="20" rx="8"/>""" &
&"""<text y="13" text-anchor="middle" font-size="36" class="glyph i{li}">{letter}</text>"""proc renderBoard(b: Board): seq[Item] =
var at: array[36, int]
for id in0 ..< 36: at[id] = -1for c, id in b.cells:
if id >= 0: at[id] = c
for id in0 ..< 36:
let c = at[id]
let pos = if c >= 0: cellPos(c) else: stackPos(id div6)
result.add Item(key: &"t{id}", pos: pos, svg: tileSvg(id div6, c >= 0and isGiven(c)))
nbText: """
And here is the entire interaction. Grabbing tile `id` lists one future per empty cell (a tile never replaces a letter already placed), snaps to the nearest within
half a cell, or else falls back to "the tile went back to its letter". Givens have no spec, so they can't be picked up.
"""
nbCodeAnd(nbJsFromCode):
proc place(b: Board, id, cell: int): Board =
result = b
let src = b.cells.find(id)
let other = b.cells[cell]
if src == cell: return
inc result.moves
if src >= 0: result.cells[src] = -1result.cells[cell] = id
if other >= 0and src >= 0: result.cells[src] = other # swapproc home(b: Board, id: int): Board =
result = b
let src = b.cells.find(id)
if src >= 0:
result.cells[src] = -1
inc result.moves
proc isHead(b: Board, at: int): bool =
## the letter on `at` is the last one spelled so far of some word: the head of its snakelet g = b.grid
for clue in puz.clues:
let path = g.trace(clue)
if path.len > 0and path[^1] == at: returntruefalseproc tileSpec(b: Board, key: string): Behavior[Board] =
let id = parseInt(key[1 .. ^1])
let src = b.cells.find(id)
if src >= 0and isGiven(src): returnnilvar futures: seq[Board]
for c in0 ..< 36: # only empty cells: a tile never pushes out a letter already placedifnot isGiven(c) and (b.cells[c] < 0or c == src): futures.add b.place(id, c)
var fallback = b.home(id) # a tile from below goes back to its letterif src >= 0: fallback = b # a tile lifted off the board stays in its cell
closest(futures).within(cell * 0.6).orElse(closest(@[fallback]))
nbText: "Driving it by hand at build time: grab the first letter's tile and hover between two cells of the top row."
nbCode:
puz = decode(builtin[0][1])
let b0 = puz.boardOf(puz.startGrid)
let free = toSeq(0 ..< 36).filterIt(b0.cells.find(it) < 0)
let key = &"t{free[0]}"let picker = tileSpec(b0, key)(renderBoard, key)
let choice = picker.pick(vec(cellPos(2).x + 30, cellPos(2).y + 4))
echo &"grabbed {key} ({letterOf(free[0])}): {picker.lands.len} futures rendered speculatively"echo &"pointer near r1c3 → shows the board with {letterOf(free[0])} at r1c{choice.state.cells.find(free[0]) + 1}, {int(choice.dist)}px away"let off = picker.pick(vec(300, 640))
echo &"pointer below the board → {off.state.cells.find(free[0])} (-1: back with its letter)"
nbText: """
Around the tiles, the backdrop draws the board and red cells for clashes; the overlay draws each word's outline
on top: a ring on its start cell that grows around the letters as they are spelled. Both are plain functions of the board too, so nimib can also draw a
finished board at build time:
"""
nbCodeAnd(nbJsFromCode):
var focusClue = -1# the clue chip under the mouse, drawn boldervar wiggleClue = -1# a tapped clue: its ring or outline on the board wiggles for a momentvar maskTag = "wm"# ids of the word-outline masks (the build-time spoiler uses its own)var coachTint: seq[int] # tutorial: cells to tint (the reason for a step)var coachTarget = -1# tutorial: the cell to fill (or empty)var coachStacks: seq[int] # tutorial: letter stacks to make glowproc backdrop(b: Board): string =
let g = b.grid
let bad = g.clashes
result = &"""<rect class="frame" x="{bx - 9}" y="{by - 9}" width="{cell * 6 + 18}" height="{cell * 6 + 18}" rx="14"/>"""for c in0 ..< 36:
let p = cellPos(c)
let (r, col) = (c div6, c mod6)
let base = if (r div2 + col div3) mod2 == 0: "ca"else: "cb"let tinted = if c in coachTint: "tint"else: base
var fill = tinted
if c in bad: fill = tinted & " bad"# while a tile is held, the page's CSS hides the redresult.add &"""<rect class="cell {fill}" x="{p.x - cell/2 + 1.5}" y="{p.y - cell/2 + 1.5}" width="{cell - 3}" height="{cell - 3}" rx="7"/>"""if c in bad:
result.add &"""<rect x="{p.x - cell/2 + 1.5}" y="{p.y - cell/2 + 1.5}" width="{cell - 3}" height="{cell - 3}" rx="7" class="badline"/>"""for i in1 .. 2:
let y = by + i.float * cell * 2result.add &"""<rect class="boxline" x="{bx}" y="{y - 2.5}" width="{cell * 6}" height="5"/>"""result.add &"""<rect class="boxline" x="{bx + cell * 3 - 2.5}" y="{by}" width="5" height="{cell * 6}"/>"""for i in0 .. 5: # row and column numbers, for the tutorial's r3c4result.add &"""<text x="{bx - 22}" y="{cellPos(i * 6).y + 5}" text-anchor="middle" font-size="14" class="mono lbl">r{i + 1}</text>"""result.add &"""<text x="{cellPos(i).x}" y="{by + cell * 6 + 28}" text-anchor="middle" font-size="14" class="mono lbl">c{i + 1}</text>"""for li in coachStacks:
let s = stackPos(li)
result.add &"""<rect class="glow pulse" x="{s.x - 38}" y="{s.y - 38}" width="76" height="76" rx="18"/>"""# a letter with no tiles left shows an empty ring and a tickvar left: array[6, int]
for id in0 ..< 36:
if b.cells.find(id) < 0: inc left[id div6]
for li in0 .. 5:
let s = stackPos(li)
if left[li] == 0:
result.add &"""<rect class="empty" x="{s.x - 31}" y="{s.y - 31}" width="62" height="62" rx="12"/>"""result.add &"""<text class="done" x="{s.x}" y="{s.y + 9}" text-anchor="middle" font-size="26">✓</text>"""proc overlay(b: Board): string =
let g = b.grid
let n = puz.clues.len
for i, clue in puz.clues:
# each word is an outline in its own dark colour: a ring on its start cell that grows around its letterslet path = g.trace(clue)
let ink = &"w{i mod 3}"let t = if focusClue == i: 8.0else: 5.0let rr = cell * 0.47if wiggleClue == i:
var (sx, sy) = (0.0, 0.0)
let cells = if path.len > 0: path else: @[idx(clue.pos)]
for c in cells:
sx += cellPos(c).x
sy += cellPos(c).y
result.add &"""<g class="wig" style="transform-origin:{sx / cells.len.float:.1f}px {sy / cells.len.float:.1f}px">"""if path.len <= 1:
let p = cellPos(idx(clue.pos))
result.add &"""<circle class="wo {ink}" cx="{p.x}" cy="{p.y}" r="{rr + t / 2:.1f}" stroke-width="{t}"/>"""else:
var d = ""for k, c in path:
let p = cellPos(c)
let cmd = if k == 0: "M"else: "L"
d.add &"{cmd}{p.x:.1f},{p.y:.1f} "# round stroke joins round the outer corners; the inner (concave) corners get a fillet each,# so the outline never has a sharp angle: one for the outer edge, a wider one for the inner edgevar fillOut, fillIn = ""let (wOut, wIn, f) = (rr + t, rr, 16.0)
for k in1 .. path.len - 2:
let (p0, p1, p2) = (cellPos(path[k - 1]), cellPos(path[k]), cellPos(path[k + 1]))
let a = vec((p1.x - p0.x) / cell, (p1.y - p0.y) / cell)
let b = vec((p2.x - p1.x) / cell, (p2.y - p1.y) / cell)
if abs(a.x * b.x + a.y * b.y) > 0.5: continue# straight on: no cornerfor (w, r, inner) in [(wOut, f, false), (wIn, f + t, true)]:
let kx = vec(p1.x + w * (b.x - a.x), p1.y + w * (b.y - a.y)) # the concave corner of this edgelet s1 = vec(kx.x - a.x * r, kx.y - a.y * r)
let s2 = vec(kx.x + b.x * r, kx.y + b.y * r)
let piece = &"M{kx.x:.1f},{kx.y:.1f}L{s1.x:.1f},{s1.y:.1f}Q{kx.x:.1f},{kx.y:.1f} {s2.x:.1f},{s2.y:.1f}Z"if inner: fillIn.add piece
else: fillOut.add piece
let m = &"{maskTag}{i}"result.add &"""<mask id="{m}" maskUnits="userSpaceOnUse" x="0" y="0" width="600" height="650">""" &
&"""<path d="{d}" class="wm" stroke="#fff" stroke-width="{2 * wOut:.1f}"/><path d="{fillOut}" fill="#fff"/>""" &
&"""<path d="{d}" class="wm" stroke="#000" stroke-width="{2 * wIn:.1f}"/><path d="{fillIn}" fill="#000"/></mask>""" &
&"""<rect class="wf {ink}" x="0" y="0" width="600" height="650" mask="url(#{m})"/>"""if wiggleClue == i: result.add "</g>"if coachTarget >= 0:
let p = cellPos(coachTarget)
result.add &"""<rect class="aim pulse" x="{p.x - 37}" y="{p.y - 37}" width="74" height="74" rx="12"/>"""proc staticSvg(b: Board): string =
maskTag = "sm"result = """<svg viewBox="40 -10 520 520"><defs><filter id="rough2"><feTurbulence type="fractalNoise" baseFrequency=".035" numOctaves="2" seed="7"/><feDisplacementMap in="SourceGraphic" scale="5"/></filter><filter id="cutout2"><feTurbulence type="fractalNoise" baseFrequency=".012" numOctaves="1" seed="3"/><feDisplacementMap in="SourceGraphic" scale="9"/></filter></defs>""" & backdrop(b)
for it in renderBoard(b):
if b.cells.find(parseInt(it.key[1 .. ^1])) >= 0:
result.add &"""<g transform="translate({it.pos.x},{it.pos.y})">{it.svg}</g>"""result.add overlay(b) & "</svg>"
maskTag = "wm"
nbCode:
let solution = puz.boardOf(puz.solve[0])
let spoiler = "<details class=\"spoiler\"><summary>spoiler: the solved first puzzle, drawn by nimib at build time</summary>" &
solution.staticSvg & "</details>"
nbRawHtml: spoiler
nbRawHtml: """<h2><span class="num">05 · THE RUNTIME</span>Pointer events in, pick calls out</h2>"""
nbText: """
The browser runtime (`std/dom`) keeps one SVG group per tile key, so a CSS transition slides tiles to their new
places whenever the shown board changes. While a tile is held it floats under the pointer, everything else shows
the picked future, and on release that future is committed.
"""
nbRawHtml: "<details><summary>show the browser runtime (std/dom, ~100 lines)</summary>"
nbCodeDisplay(nbJsFromCode):
import std/[dom, tables]
proc svgNode(tag: cstring): Element {.importjs: "document.createElementNS('http://www.w3.org/2000/svg', #)".}
proc capture(e: Element, ev: Event) {.importjs: "#.setPointerCapture(#.pointerId)".}
proc keyAt(ev: Event): cstring {.importjs: "(#.target.closest('[data-key]')?.dataset.key ?? null)".}
proc setStyle(e: Element, k, v: cstring) {.importjs: "#.style.setProperty(#, #)".}
proc checked(e: Element): bool {.importjs: "#.checked".}
proc clientXY(ev: Event): Vec {.importjs: "(e => ({x: e.clientX, y: e.clientY}))(#)".}
proc setTimer(cb: proc (), ms: int): int {.importjs: "setTimeout(#, #)".}
proc clearTimer(t: int) {.importjs: "clearTimeout(#)".}
proc buzz() {.importjs: "(navigator.vibrate && navigator.vibrate(12))".}
proc rectOf(e: Element): (float, float, float, float) {.importjs:
"(() => { const r = #.getBoundingClientRect(); return {Field0: r.left, Field1: r.top, Field2: r.width, Field3: r.height}; })()".}
typeStage[S] = refobject
state: S
render: Render[S]
spec: proc (s: S, key: string): Behavior[S]
backdrop, overlay: proc (s: S): string
svg, bg, fg, top, ghosts: Element
nodes: Table[string, Element]
looks: Table[string, string]
dragKey: string
grab, at: Vec
picker: Picker[S]
choice: Choice[S]
showGhosts: bool
onCommit: proc ()
onMove: proc () # after every pointer move and drop: effects drawn outside the view
redirect: proc (s: S, key: string): string# grabbing `key` may lift another item instead ("" = nothing)
origin: Vec# where the held item was grabbed
redirected: bool
holdMs: int# how long a press on a redirected item waits before lifting the item itselfproc tr(p: Vec): cstring = cstring(&"translate({p.x:.2f}px, {p.y:.2f}px)")
proc drawGhosts[S](st: Stage[S]) =
var g = ""if st.dragKey.len > 0:
if st.showGhosts:
for l in st.picker.lands:
if l.x == l.x: g.add &"""<circle class="land" cx="{l.x}" cy="{l.y}" r="6"/>"""if st.choice.ok:
let c = st.choice.land
g.add &"""<rect class="target" x="{c.x - 36}" y="{c.y - 36}" width="72" height="72" rx="13"/>"""if st.showGhosts:
g.add &"""<line class="tether" x1="{st.at.x}" y1="{st.at.y}" x2="{c.x}" y2="{c.y}"/>"""
st.ghosts.innerHTML = cstring(g)
proc draw[S](st: Stage[S], s: S) =
st.bg.innerHTML = cstring(st.backdrop(s))
for it in st.render(s):
let held = it.key == st.dragKey
var n = st.nodes.getOrDefault(it.key)
if n.isNil:
n = svgNode("g")
n.setAttribute("data-key", cstring(it.key))
n.setStyle("transform", tr(it.pos))
st.fg.appendChild(n)
st.nodes[it.key] = n
if st.looks.getOrDefault(it.key) != it.svg:
n.innerHTML = cstring(it.svg)
st.looks[it.key] = it.svg
let grab = if st.spec(s, it.key).isNil: ""else: " grab"let lifted = if held: " lifted"else: ""let pos = if held: st.at else: it.pos
n.setAttribute("class", cstring("item" & grab & lifted))
n.setStyle("transform", tr(pos))
st.top.innerHTML = cstring(st.overlay(s))
st.drawGhosts()
proc move[S](st: Stage[S], p: Vec) =
st.at = p - st.grab
st.choice = st.picker.pick(st.at)
let shown = if st.choice.ok: st.choice.state else: st.state
st.draw(shown)
ifnot st.onMove.isNil: st.onMove()
proc start[S](st: Stage[S], grabbed: string, p: Vec, redirect = true) =
var key = grabbed
if redirect andnot st.redirect.isNil: key = st.redirect(st.state, grabbed)
if key.len == 0: returnlet behavior = st.spec(st.state, key)
if behavior.isNil: return
st.dragKey = key
st.redirected = key != grabbed
st.origin = st.render.locate(st.state, grabbed)
st.grab = p - st.origin # a redirected item starts where the grabbed one is
st.picker = behavior(st.render, key) # speculative renders happen here
st.fg.appendChild(st.nodes[key]) # float above the others
st.svg.classList.add("dragging")
st.move(p)
proc finish[S](st: Stage[S]) =
if st.dragKey.len == 0: returnlet key = st.dragKey
let was = st.render.locate(st.state, key)
if st.choice.ok: st.state = st.choice.state
let now = st.render.locate(st.state, key)
st.dragKey = ""
st.svg.classList.remove("dragging")
let n = st.nodes[key]
let snap = st.redirected and now.x == was.x and now.y == was.y
if snap: n.setStyle("transition", "none") # a copy let go nowhere melts back into the grabbed one: no glide
st.draw(st.state) # the held tile glides homeif snap:
discard rectOf(n)
n.setStyle("transition", "")
ifnot st.onMove.isNil: st.onMove()
ifnot st.onCommit.isNil: st.onCommit()
proc setState[S](st: Stage[S], s: S) =
st.state = s
st.draw(s)
ifnot st.onCommit.isNil: st.onCommit()
proc mount[S](host: Element, w, h: float, init: S, render: Render[S],
spec: proc (s: S, key: string): Behavior[S],
backdrop, overlay: proc (s: S): string, x0 = 0.0): Stage[S] =
host.innerHTML = cstring(&"""<svg class="d2" viewBox="{x0} 0 {w} {h}"><defs><filter id="rough"><feTurbulence type="fractalNoise" baseFrequency=".035" numOctaves="2" seed="7"/><feDisplacementMap in="SourceGraphic" scale="5"/></filter><filter id="cutout"><feTurbulence type="fractalNoise" baseFrequency=".012" numOctaves="1" seed="3"/><feDisplacementMap in="SourceGraphic" scale="9"/></filter></defs><g class="bg"></g>""" &
"""<g class="fg"></g><g class="overlay"></g><g class="ghosts"></g></svg>""" &
"""<label class="ghost-toggle"><input type="checkbox"> <span data-it="mostra il meccanismo">show machinery</span></label>""")
let st = Stage[S](state: init, render: render, spec: spec, backdrop: backdrop, overlay: overlay, holdMs: 320)
st.svg = host.querySelector("svg")
st.bg = host.querySelector(".bg")
st.fg = host.querySelector(".fg")
st.top = host.querySelector(".overlay")
st.ghosts = host.querySelector(".ghosts")
let toLocal = proc (ev: Event): Vec =
let (l, t, rw, rh) = rectOf(st.svg)
let c = clientXY(ev)
vec(x0 + (c.x - l) * w / rw, (c.y - t) * h / rh)
# an item that a grab redirects (a letter on the board) waits a moment: moving at once pulls the redirected one# (a copy); holding still lifts the item itself, to move itvar pendKey = ""var pendAt = vec(0, 0)
var pendTimer = 0
st.svg.addEventListener("pointerdown", proc (ev: Event) =
let k = keyAt(ev)
if k.isNil: return
ev.preventDefault()
capture(st.svg, ev)
let p = toLocal(ev)
var r = $k
ifnot st.redirect.isNil: r = st.redirect(st.state, $k)
if r.len == 0: return# "": the page handles this grab itselfif r == $k or st.spec(st.state, $k).isNil:
st.start($k, p)
return
pendKey = $k
pendAt = p
pendTimer = setTimer(proc () =
if pendKey.len > 0:
let key = pendKey
pendKey = ""
buzz()
st.start(key, pendAt, redirect = false), st.holdMs))
st.svg.addEventListener("pointermove", proc (ev: Event) =
let p = toLocal(ev)
if pendKey.len > 0and norm(p - pendAt) > 6:
clearTimer(pendTimer)
let key = pendKey
pendKey = ""
st.start(key, pendAt)
if st.dragKey.len > 0: st.move(p))
let up = proc (ev: Event) =
if pendKey.len > 0:
clearTimer(pendTimer)
pendKey = ""
st.finish()
st.svg.addEventListener("pointerup", up)
st.svg.addEventListener("pointercancel", up)
let box = host.querySelector(".ghost-toggle input")
box.addEventListener("change", proc (ev: Event) =
st.showGhosts = box.checked
st.drawGhosts())
st.draw(init)
st
nbRawHtml: "</details>"# ---------------------------------------------------------------- the game wiring (hidden)var codes = ""for (title, code) in builtin: codes.add "<option value=\"" & code & "\">" & title & "</option>"
nbRawHtml: "<template id=\"builtin\">" & codes & "</template>"
nbJsFromCode:
proc now(): float {.importjs: "Date.now()".}
proc every(cb: proc (), ms: int) {.importjs: "setInterval(#, #)".}
proc search(): cstring {.importjs: "String(location.search)".}
proc pageUrl(): cstring {.importjs: "(location.origin + location.pathname)".}
proc pushQuery(q: cstring) {.importjs: "history.replaceState(null, '', '?' + #)".}
proc lsGet(k: cstring): cstring {.importjs: "(k => { try { return localStorage.getItem(k) } catch (e) { return null } })(#)".}
proc lsSet(k, v: cstring) {.importjs: "((k, v) => { try { localStorage.setItem(k, v) } catch (e) {} })(#, #)".}
proc copyText(s: cstring) {.importjs: "(s => { try { navigator.clipboard.writeText(s) } catch (e) {} })(#)".}
proc rnd(): float {.importjs: "Math.random()".}
proc attr(e: Element, k: cstring): cstring {.importjs: "#.getAttribute(#)".}
proc options(): seq[Element] {.importjs: "Array.from(document.getElementById('builtin').content.querySelectorAll('option'))".}
proc hoverIndex(ev: Event): cstring {.importjs: "(#.target.closest('[data-clue]')?.dataset.clue ?? null)".}
proc navLang(): cstring {.importjs: "String(navigator.language || 'en')".}
proc i18nNodes(): seq[Element] {.importjs: "Array.from(document.querySelectorAll('[data-it]'))".}
proc setAttr(e: Element, k, v: cstring) {.importjs: "#.setAttribute(#, #)".}
proc setDocLang(l: cstring) {.importjs: "document.documentElement.lang = #".}
proc setHidden(e: Element, h: bool) {.importjs: "#.hidden = #".}
proc dataOf(ev: Event, k: cstring): cstring {.importjs: "((e, k) => e.target.closest('[data-' + k + ']')?.getAttribute('data-' + k) ?? null)(#, #)".}
let host = document.getElementById("board")
let cluesEl = document.getElementById("clues")
let pickerEl = document.getElementById("picker")
var games: seq[(string, string)]
for o in options(): games.add ($o.attr("value"), $o.innerHTML)
var code = games[0][0]
let fromUrl = $search()
if fromUrl.contains("p=") and decode(fromUrl).valid:
code = decode(fromUrl).encode
if games.allIt(it[0] != code): games.add (code, "")
# language: ?lang=, then the last choice, then the browser'slet savedLang = lsGet("parloku:lang")
lang = if ($navLang()).startsWith("it"): "it"else: "en"ifnot savedLang.isNil and $savedLang in ["en", "it"]: lang = $savedLang
if fromUrl.contains("lang=it"): lang = "it"if fromUrl.contains("lang=en"): lang = "en"var solution: Gridvar started = 0.0var wonAt = 0.0var celebrated = falseproc emptyBoard(): Board = puz.boardOf(puz.startGrid)
puz = decode(code)
let stage = mount(host, 500, 664, emptyBoard(), renderBoard, tileSpec, backdrop, overlay, 50) # cropped to the board: bigger on a phone# letters on the board stay put: grabbing one pulls out a copy of it (a double tap sends it back)
stage.redirect = proc (b: Board, key: string): string =
let id = parseInt(key[1 .. ^1])
let at = b.cells.find(id)
if at < 0: return key
if b.isHead(at): return""# a snake's head is drawn by hand (below): no copyfor k in0 .. 5:
if b.cells.find((id div6) * 6 + k) < 0: return &"t{(id div 6) * 6 + k}"""block:
let slot = document.getElementById("machinery-slot")
let toggle = host.querySelector(".ghost-toggle")
if slot.isNil: toggle.setHidden(true)
else: slot.appendChild(toggle)
# ---- tiles that divide (after the garden devlog): pulling a copy off its letter grows a neck until it splits off;# bringing a tile back reaches out to its letter and the two merge. Drawn under the tiles, from the stage's pick.proc later(cb: proc (), ms: int) {.importjs: "setTimeout(#, #)".}
func mul(v: Vec, k: float): Vec = vec(v.x * k, v.y * k)
func waist(d, r: float): float =
## half-width of the neck between two tiles of radius r whose centres are d apartlet t = clamp((d - 0.4 * r) / (4.2 * r), 0.0, 1.0)
r * (0.85 - 0.74 * t)
func neckPath(c1, c2: Vec, r, hw: float): string =
## an SVG path joining two circles of radius r with a waist of half-width hwlet d = norm(c2 - c1)
if d < 0.2 * r or hw < 0.3: return""let u = mul(c2 - c1, 1 / d)
let n = vec(-u.y, u.x)
let a = arcsin(min(0.94, 1.05 * hw / r + 0.32))
let m = c1 + mul(u, d / 2)
var side: array[2, string]
for k, sgn in [1.0, -1.0]:
let p1 = c1 + mul(mul(u, cos(a)) + mul(n, sgn * sin(a)), r)
let t1 = mul(u, sin(a)) - mul(n, sgn * cos(a))
let p2 = c2 + mul(mul(u, -cos(a)) + mul(n, sgn * sin(a)), r)
let t2 = mul(u, -sin(a)) - mul(n, sgn * cos(a))
let w = m + mul(n, sgn * hw)
let l = norm(p1 - w) * 0.5let h = clamp(1.4 * (r * sin(a) - hw) / max(cos(a), 0.2), 1.0, l)
let (q1, q2, q3, q4) = (p1 + mul(t1, h), w - mul(u, l), w + mul(u, l), p2 + mul(t2, h))
if k == 0:
side[k] = &"M{c1.x:.1f} {c1.y:.1f}L{p1.x:.1f} {p1.y:.1f}C{q1.x:.1f} {q1.y:.1f} {q2.x:.1f} {q2.y:.1f} {w.x:.1f} {w.y:.1f}" &
&"C{q3.x:.1f} {q3.y:.1f} {q4.x:.1f} {q4.y:.1f} {p2.x:.1f} {p2.y:.1f}L{c2.x:.1f} {c2.y:.1f}"else:
side[k] = &"L{p2.x:.1f} {p2.y:.1f}C{q4.x:.1f} {q4.y:.1f} {q3.x:.1f} {q3.y:.1f} {w.x:.1f} {w.y:.1f}" &
&"C{q2.x:.1f} {q2.y:.1f} {q1.x:.1f} {q1.y:.1f} {p1.x:.1f} {p1.y:.1f}Z"
side[0] & side[1]
let necks = svgNode("g")
necks.setAttribute("class", "necks")
stage.svg.insertBefore(necks, stage.fg)
let fx = svgNode("g")
fx.setAttribute("class", "fx")
stage.svg.insertBefore(fx, stage.fg)
var joined = falseproc ripple(at: Vec, li: int) =
## the letter left behind wobbles when its copy splits offlet e = svgNode("circle")
e.setAttribute("class", cstring(&"ripple g{li}"))
e.setAttribute("cx", cstring($at.x))
e.setAttribute("cy", cstring($at.y))
e.setAttribute("r", "32")
fx.appendChild(e)
later(proc () = fx.removeChild(e), 500)
stage.onMove = proc () =
let held = stage.dragKey
var d = ""var li = 0var pulling = falseif held.len > 0:
let id = parseInt(held[1 .. ^1])
li = id div6let fromCell = stage.state.cells.find(id)
let home = stage.origin # the letter it was pulled from: below, or on the boardlet dd = norm(stage.at - home)
let atHome = stage.choice.ok and stage.choice.state.cells.find(id) < 0var others = false# copies left under the held one: then it divides, else it moves wholefor k in0 .. 5:
if li * 6 + k != id and stage.state.cells.find(li * 6 + k) < 0: others = trueif stage.redirected: others = true# the board letter it came from staysif fromCell < 0and others and atHome:
pulling = true
d = neckPath(home, stage.at, 31, waist(dd, 31))
elif fromCell >= 0and atHome and dd < 2.6 * cell:
d = neckPath(stage.at, home, 31, waist(dd, 31))
if joined andnot pulling and fromCell < 0and others: ripple(home, li)
joined = pulling
if d.len > 0: necks.innerHTML = cstring(&"""<path class="neck g{li}" d="{d}"/>""")
else: necks.innerHTML = ""# ---- drawing a snake: hold its head and walk. Onto a free neighbour, the word's next letter is added;# back onto the cell before, the head letter goes back below. With no step taken, letting go on an empty# cell moves the head there.var snakeWord = -1var snakePath: seq[int]
var snakeKey = ""var snakeStepped = falsevar snakeDown = vec(0, 0)
proc toBoard(ev: Event): Vec =
let (l, t0, rw, rh) = rectOf(stage.svg)
let c = clientXY(ev)
vec(50 + (c.x - l) * 500 / rw, (c.y - t0) * 664 / rh)
proc cellNear(p: Vec): int =
## the cell whose centre is close to p (-1 between cells or off the board)let (col, row) = (int(floor((p.x - bx) / cell)), int(floor((p.y - by) / cell)))
if col notin0 .. 5or row notin0 .. 5: return -1let c = row * 6 + col
if norm(p - cellPos(c)) < cell * 0.42: c else: -1
stage.svg.addEventListener("pointerdown", proc (ev: Event) =
let k = keyAt(ev)
snakeWord = -1if k.isNil: returnlet id = parseInt(($k)[1 .. ^1])
let at = stage.state.cells.find(id)
if at < 0: returnlet g = stage.state.grid
for i, clue in puz.clues: # an unfinished word first: its head is the one to growlet path = g.trace(clue)
if path.len > 0and path[^1] == at and (snakeWord < 0or path.len < clue.word.len):
snakeWord = i
snakePath = path
if snakeWord < 0: return
snakeKey = $k
snakeStepped = false
snakeDown = toBoard(ev))
stage.svg.addEventListener("pointermove", proc (ev: Event) =
if snakeWord < 0: returnlet p = toBoard(ev)
let head = snakePath[^1]
let c = cellNear(p)
var b = stage.state
let word = puz.clues[snakeWord].word
if c >= 0and snakePath.len >= 2and c == snakePath[^2] andnot isGiven(head):
b = b.home(b.cells[head]) # back: the head letter returns belowdiscard snakePath.pop
snakeStepped = true
stage.setState(b)
elif c >= 0and b.cells[c] < 0andnot isGiven(c) and snakePath.len < word.len and
abs(c div6 - head div6) + abs(c mod6 - head mod6) == 1:
let li = puz.letters.find(word[snakePath.len])
for j in0 .. 5:
if b.cells.find(li * 6 + j) < 0:
snakePath.add c
snakeStepped = true
stage.setState(b.place(li * 6 + j, c))
breakifnot snakeStepped and norm(p - snakeDown) > 6andnot isGiven(head):
let n = stage.nodes[snakeKey] # no step yet: the head follows the finger, to be moved
n.setAttribute("class", "item grab lifted")
n.setStyle("transform", tr(p))
elif snakeStepped:
stage.draw(stage.state))
let snakeUp = proc (ev: Event) =
if snakeWord < 0: returnlet w = snakeWord
snakeWord = -1let head = snakePath[^1]
let p = toBoard(ev)
ifnot snakeStepped and norm(p - snakeDown) > 6andnot isGiven(head):
let (col, row) = (int(floor((p.x - bx) / cell)), int(floor((p.y - by) / cell)))
let c = row * 6 + col
if col in0 .. 5and row in0 .. 5and stage.state.cells[c] < 0andnot isGiven(c):
stage.setState(stage.state.place(stage.state.cells[head], c)) # movedreturndiscard w
stage.draw(stage.state) # glide back
stage.svg.addEventListener("pointerup", snakeUp)
stage.svg.addEventListener("pointercancel", snakeUp)
# ---- a double tap on a tile on the board sends it (and the rest of its snake) back to its letter below; on an empty cell with only one possible letter (a start ring, or the last gap of a row, column or box), it fills that letter invar tapKey = ""var tapAt = 0.0var downAt = vec(0, 0)
var downKey: cstring = nil# the tile under the finger when it went down (pointerup lands on the svg: it is captured)
stage.svg.addEventListener("pointerdown", proc (ev: Event) =
downKey = keyAt(ev)
downAt = clientXY(ev))
stage.svg.addEventListener("pointerup", proc (ev: Event) =
let k = downKey
let up = clientXY(ev)
if norm(up - downAt) > 8: # a drag, not a tap
tapKey = ""returnif k.isNil:
# a double tap on an empty start cell puts the word's first letter therelet (l, t0, rw, rh) = rectOf(stage.svg)
let x = 50 + (up.x - l) * 500 / rw
let y = (up.y - t0) * 664 / rh
let (col, row) = (int(floor((x - bx) / cell)), int(floor((y - by) / cell)))
if col notin0 .. 5or row notin0 .. 5:
tapKey = ""returnlet c = row * 6 + col
# the only letter that can go there: a start ring's first letter, or the one missing from a row,# column or box that already has the other five, all differentvar forced = '.'for clue in puz.clues:
if idx(clue.pos) == c: forced = clue.word[0]
if forced == '.':
let g = stage.state.grid
var units: seq[seq[int]]
units.add toSeq(0 .. 5).mapIt(row * 6 + it)
units.add toSeq(0 .. 5).mapIt(it * 6 + col)
let (r0, c0) = (row div2 * 2, col div3 * 3)
units.add toSeq(0 .. 5).mapIt((r0 + it div3) * 6 + c0 + it mod3)
for u in units:
var seen: seq[char]
for cc in u:
if cc != c and g[cc] != '.'and g[cc] notin seen: seen.add g[cc]
if seen.len == 5:
for ch in puz.letters:
if ch notin seen: forced = ch
breakif forced == '.'or stage.state.cells[c] >= 0:
tapKey = ""returnlet t = now()
if tapKey == &"c{c}"and t - tapAt < 400:
tapKey = ""let li = puz.letters.find(forced)
for j in0 .. 5:
if stage.state.cells.find(li * 6 + j) < 0:
stage.setState(stage.state.place(li * 6 + j, c))
breakelse:
tapKey = &"c{c}"
tapAt = t
returnlet id = parseInt(($k)[1 .. ^1])
let at = stage.state.cells.find(id)
if at < 0: return# the whole snake goes: every word whose outline passes through this letter sends all its letters# back below (givens stay where they are)var gone: seq[int]
let g = stage.state.grid
for clue in puz.clues:
let path = g.trace(clue)
if path.len >= 2and at in path:
for c in path:
if c notin gone: gone.add c
if gone.len == 0: gone.add at
if gone.allIt(isGiven(it)): returnlet t = now()
if tapKey == $k and t - tapAt < 400:
tapKey = ""var b = stage.state
for c in gone:
ifnot isGiven(c) and b.cells[c] >= 0: b = b.home(b.cells[c])
stage.setState(b)
else:
tapKey = $k
tapAt = t)
# ---- on the game page, only the game: tap an empty spot to show the rest, and again to hide itproc interactive(ev: Event): bool {.importjs:
"!!#.target.closest('button,a,input,label,select,summary,.clues,.stage,.coach,.skins,.picker,.status,.sp')".}
proc scrollTop() {.importjs: "window.scrollTo(0, 0)".}
proc inPlay(ev: Event): bool {.importjs: "!!#.target.closest('.play')".}
var hideTimer = 0proc setMode(m: string)
proc armHide() =
## the menu bar goes away by itself after a few idle seconds
clearTimer(hideTimer)
hideTimer = setTimer(proc () =
if document.body.classList.contains("chrome"): setMode("focus"), 4000)
proc setMode(m: string) =
for k in ["focus", "chrome", "explain"]:
if k == m: document.body.classList.add(cstring(k))
else: document.body.classList.remove(cstring(k))
if m == "chrome":
armHide()
let playEl = document.getElementById("play")
ifnot playEl.isNil:
setMode("focus")
# one menu bar, at the top only: the puzzles and restartlet bar = document.querySelector(".picker")
bar.appendChild(document.getElementById("reset"))
for ev in ["pointerdown", "scroll"]:
bar.addEventListener(cstring(ev), proc (e: Event) = armHide())
document.addEventListener("click", proc (ev: Event) =
if interactive(ev): returnif document.body.classList.contains("focus"): setMode("chrome")
elif document.body.classList.contains("chrome"): setMode("focus")
elif inPlay(ev):
setMode("focus")
scrollTop())
let banner = document.createElement("div")
banner.className = "banner"
host.appendChild(banner)
let confetti = document.createElement("div")
confetti.className = "confetti"
host.appendChild(confetti)
proc clock(): string =
let t0 = if wonAt > 0: wonAt else: now()
let s = if started > 0: int((t0 - started) / 1000) else: 0
&"{s div 60}:{s mod 60:02}"proc showClues() =
let g = stage.state.grid
var html = ""for i, clue in puz.clues:
let path = g.trace(clue)
let full = path.len == clue.word.len
let cls = if full: " done"else: ""let foc = if focusClue == i: " focus"else: ""
html.add &"""<div class="clue{cls}{foc}" data-clue="{i}" style="border-color:var(--w{i mod 3})">"""for k, ch in clue.word:
let li = puz.letters.find(ch)
if k < path.len:
html.add &"""<span class="l on" style="background:var(--g{li});color:var(--i{li})">{ch}</span>"""else:
html.add &"""<span class="l">{ch}</span>"""let tick = if full: "✓"else: ""
html.add &"""<span class="ok">{tick}</span></div>"""
cluesEl.innerHTML = cstring(html)
var tutorial = false# the 🎓 tutorial runs on the first puzzle, with its own saved boardproc storeKey(): cstring =
let prefix = if tutorial: "parloku:tutorial:"else: "parloku:"cstring(prefix & code)
proc save() =
lsSet(storeKey(), cstring(stage.state.cells.join(",") & ";" & $stage.state.moves & ";" & $started & ";" & $wonAt))
proc celebrate() =
var bits = ""for k in0 ..< 60:
let li = int(rnd() * 6) mod6let x = int(rnd() * 100)
let dx = int(rnd() * 160) - 80let r = int(rnd() * 720) - 360let t = 1.6 + rnd() * 1.6let d = rnd() * 0.6
bits.add &"""<i style="--x:{x}%;--dx:{dx}px;--r:{r}deg;--t:{t:.2f}s;--d:{d:.2f}s;background:var(--g{li});color:var(--i{li})">{puz.letters[li]}</i>"""
confetti.innerHTML = cstring(bits)
let coachEl = document.getElementById("coach")
let coachHd = document.getElementById("coach-hd")
let coachText = document.getElementById("coach-text")
let coachActs = document.getElementById("coach-acts")
var intro = 0# 0 .. 3: the intro pages, 4: solvingvar guided = true# false once the player chose to finish the sudoku alonevar wordsAck = false# the "all words are in" page was seenvar coachStep: Stepproc showCoach()
proc refresh() =
let g = stage.state.grid
let won = solved(puz, g)
if won and wonAt == 0: wonAt = now()
if won andnot celebrated:
celebrated = true
banner.innerHTML = cstring(&"<b>Bravo!</b><span>{stage.state.moves} " & L("moves", "mosse") & &" · {clock()}</span>")
celebrate()
if won: host.classList.add("won")
else: host.classList.remove("won")
showClues()
showCoach()
var history: seq[Board] # boards before each change, for ↶ undovar last = stage.state
let undoBtn = document.getElementById("undo")
proc setDisabled(e: Element, d: bool) {.importjs: "#.disabled = #".}
stage.onCommit = proc () =
if started == 0and stage.state.moves > 0: started = now()
if stage.state.cells != last.cells:
history.add last
last = stage.state
undoBtn.setDisabled(history.len == 0)
refresh()
save()
proc load(c: string) =
code = c
puz = decode(c)
let sols = puz.solve(limit = 1)
for i in0 ..< 36: solution[i] = '.'if sols.len > 0: solution = sols[0]
var b = emptyBoard()
started = 0
wonAt = 0let saved = lsGet(storeKey())
ifnot saved.isNil:
let parts = ($saved).split(';')
let cells = parts[0].split(',')
if parts.len == 4and cells.len == 36:
for i in0 ..< 36: b.cells[i] = parseInt(cells[i])
b.moves = parseInt(parts[1])
started = parseFloat(parts[2])
wonAt = parseFloat(parts[3])
celebrated = solved(puz, b.grid) # don't throw confetti again on reload
confetti.innerHTML = ""
banner.innerHTML = cstring(&"<b>Bravo!</b><span>{b.moves} " & L("moves", "mosse") & &" · {clock()}</span>")
var html = ""for i, (gc, title) in games:
let on = if gc == c andnot tutorial: " on"else: ""let name = if title == "": L("from the link", "dal link") else: title
html.add &"""<button class="btn num{on}" data-i="{i}" aria-label="{L("puzzle", "puzzle")} {i + 1}: {name}">{name}</button>"""# named by its three words
pickerEl.innerHTML = cstring(html)
stage.setState(b)
history = @[]
last = stage.state
undoBtn.setDisabled(true)
proc dataI(ev: Event): cstring {.importjs: "(#.target.closest('[data-i]')?.dataset.i ?? null)".}
pickerEl.addEventListener("click", proc (ev: Event) =
let i = dataI(ev)
ifnot i.isNil:
ifnot playEl.isNil: setMode("focus")
tutorial = false
lsSet(cstring("parloku:" & games[parseInt($i)][0]), "") # picking a puzzle always starts it afresh
load(games[parseInt($i)][0])
pushQuery(cstring("p=" & code)))
let resetBtn = document.getElementById("reset")
resetBtn.addEventListener("click", proc (ev: Event) = # restart right away: no "sure?"
lsSet(storeKey(), "")
load(code))
undoBtn.addEventListener("click", proc (ev: Event) =
if history.len == 0: return
last = history.pop
stage.setState(last))
document.getElementById("share").addEventListener("click", proc (ev: Event) =
let url = $pageUrl() & "?p=" & code
copyText(cstring(url))
pushQuery(cstring("p=" & code))
let btn = document.getElementById("share")
btn.innerHTML = cstring(L("✓ copied", "✓ copiato")))
proc placeCorrect(c: int) =
## glide a tile of the right letter into cell `c`: one still on its stack, else one sitting in a wrong celllet b = stage.state
let g = b.grid
let li = puz.letters.find(solution[c])
if li < 0: returnvar id = -1for k in0 .. 5:
if id < 0and b.cells.find(li * 6 + k) < 0: id = li * 6 + k
for k in0 .. 5:
let at = b.cells.find(li * 6 + k)
if id < 0and at >= 0and g[at] != solution[at]: id = li * 6 + k
if id < 0: returnif started == 0: started = now()
stage.setState(b.home(id).place(id, c))
document.getElementById("hint").addEventListener("click", proc (ev: Event) =
## one correct tile glides in, into an empty or wrong celllet g = stage.state.grid
var wrong: seq[int]
for c in0 ..< 36:
if solution[c] != '.'and g[c] != solution[c]: wrong.add c
if wrong.len > 0: placeCorrect(wrong[int(rnd() * wrong.len.float) mod wrong.len]))
# ---- tap a clue word: its start ring (or its outline so far) wiggles on the board, to show where it is
cluesEl.addEventListener("click", proc (ev: Event) =
let i = hoverIndex(ev)
if i.isNil: return
wiggleClue = parseInt($i)
stage.top.innerHTML = cstring(overlay(stage.state))
discard setTimer(proc () =
wiggleClue = -1if stage.dragKey.len == 0: stage.top.innerHTML = cstring(overlay(stage.state)), 700))
# ---- the 🎓 tutorial: a coach above the board that asks the logical path for the next move after every dropproc buttons(acts: seq[(string, string)]): string =
for (act, label) in acts: result.add &"""<button class="btn" data-act="{act}">{label}</button>"""proc introText(): string =
case intro
of0: L("Welcome! This is the first Parloku. Fill the grid with the six letters <b>A I L P S T</b>, pulling copies out of the six letters below the board.",
"Benvenuto! Questo è il primo Parloku. Riempi la griglia con le sei lettere <b>A I L P S T</b>, tirando fuori delle copie dalle sei lettere sotto la griglia.")
of1: L("As in sudoku, each letter appears once in every row, every column and every 2×3 box. One of each is highlighted.",
"Come nel sudoku, ogni lettera compare una volta in ogni riga, in ogni colonna e in ogni riquadro 2×3. Ne vedi evidenziato uno per tipo.")
of2: L("The three words above are the clues. Each one starts in the cell ringed in its colour and goes one step at a time: up, down, left or right.",
"Le tre parole in alto sono gli indizi. Ognuna parte dalla casella con il cerchio del suo colore e procede un passo alla volta: in su, in giù, a destra o a sinistra.")
else: L("The method: first put the first letter of every word. Then follow each word letter by letter. When the sudoku leaves only one cell for the next letter, that letter is forced. Let's start!",
"Il metodo: prima metti la prima lettera di ogni parola. Poi segui ogni parola lettera per lettera. Quando il sudoku lascia una sola casella per la lettera dopo, quella lettera è obbligata. Cominciamo!")
proc showCoach() =
coachTint = @[]
coachTarget = -1
coachStacks = @[]
if tutorial: host.classList.add("tutoring")
else: host.classList.remove("tutoring")
ifnot tutorial:
coachEl.setHidden(true)
stage.draw(stage.state)
return
coachEl.setHidden(false)
let g = stage.state.grid
var text = ""var acts: seq[(string, string)]
let filled = toSeq(0 ..< 36).countIt(g[it] != '.')
coachHd.innerHTML = cstring(L("🎓 TUTORIAL", "🎓 TUTORIAL") & &" · {filled}/36")
if intro < 4:
text = introText()
acts = @[("next", L("next →", "avanti →")), ("exit", L("exit", "esci"))]
if intro == 0: coachStacks = toSeq(0 .. 5)
if intro == 1: coachTint = units[2] & units[10] & units[12]
if intro == 2: coachTint = puz.clues.mapIt(idx(it.pos))
elif solved(puz, g):
text = L("<b>Bravo, solved!</b> Now try puzzle 2, AMICA · GIOCO · MAGIA, on your own: the same method works.",
"<b>Bravo, risolto!</b> Ora prova da solo il puzzle 2, AMICA · GIOCO · MAGIA: il metodo è lo stesso.")
acts = @[("exit", L("close", "chiudi"))]
else:
let s = logicalStep(puz, g, solution)
coachStep = s
if s.kind in {skNaked, skHidden, skReveal} andnot wordsAck:
text = L("<b>All three words are in!</b> The rest is plain sudoku: from now on, every move is a cell with only one letter left. I can keep guiding you, or you can finish on your own.",
"<b>Le tre parole sono al loro posto!</b> Il resto è sudoku puro: da qui in poi ogni mossa è una casella con una sola lettera possibile. Posso continuare a guidarti, oppure puoi finire da solo.")
acts = @[("guide", L("keep guiding me", "continua a guidarmi")), ("alone", L("on my own", "faccio da solo"))]
elifnot guided and s.kind != skFix:
coachEl.setHidden(true)
else:
text = explain(puz, g, s)
if s.kind != skFix:
text.add L(&" Drag {an(s.letter)} <b>{s.letter}</b> there.", &" Trascina lì una <b>{s.letter}</b>.")
coachStacks = @[puz.letters.find(s.letter)]
coachTarget = s.cell
case s.kind
of skWord: coachTint = neighbours(s.path[^2]).filterIt(it != s.cell and it notin s.path)
of skNaked: coachTint = peersOf[s.cell]
of skHidden: coachTint = units[s.unit]
else: discard
acts = @[("show", L("show me", "fammi vedere")), ("exit", L("exit", "esci"))]
coachText.innerHTML = cstring(text)
coachActs.innerHTML = cstring(buttons(acts))
stage.draw(stage.state)
proc startTutorial() =
tutorial = true
intro = 0
guided = true
wordsAck = false
code = games[0][0]
lsSet(storeKey(), "")
load(code)
document.getElementById("coach").scrollIntoView()
coachActs.addEventListener("click", proc (ev: Event) =
let act = dataOf(ev, "act")
if act.isNil: returncase $act
of"next": inc intro
of"guide": wordsAck = trueof"alone":
wordsAck = true
guided = falseof"show":
let s = coachStep
if s.kind == skFix:
let b = stage.state
stage.setState(b.home(b.cells[s.cell]))
elif s.cell >= 0: placeCorrect(s.cell)
return# setState already asked the coach for the next stepof"exit":
tutorial = false
load(code)
returnelse: discard
showCoach())
document.getElementById("tutorial").addEventListener("click", proc (ev: Event) =
ifnot playEl.isNil: setMode("focus")
startTutorial())
# ---- English / Italianlet langsEl = document.getElementById("langs")
proc applyLang() =
setDocLang(cstring(lang))
lsSet("parloku:lang", cstring(lang))
for e in i18nNodes():
if e.attr("data-en").isNil: e.setAttr("data-en", e.innerHTML)
e.innerHTML = e.attr(cstring("data-" & lang))
for b in langsEl.querySelectorAll("button"):
if $b.attr("data-lang") == lang: b.classList.add("on")
else: b.classList.remove("on")
load(code)
langsEl.addEventListener("click", proc (ev: Event) =
let l = dataOf(ev, "lang")
if l.isNil: return
lang = $l
applyLang())
# ---- skins: the look of the page, remembered per browser, or ?skin=nameproc setSkin(e: Element, v: cstring) {.importjs: "#.dataset.skin = #".}
let skinsEl = document.getElementById("skins")
proc applySkin(name: string) =
document.body.setSkin(cstring(name))
lsSet("parloku:look", cstring(name))
for b in skinsEl.querySelectorAll("button"):
if $b.attr("data-skin") == name: b.classList.add("on")
else: b.classList.remove("on")
var skin = "niki"let savedSkin = lsGet("parloku:look")
ifnot savedSkin.isNil and ($savedSkin).len > 0: skin = $savedSkin
let si = fromUrl.find("skin=")
if si >= 0: skin = fromUrl[si + 5 .. ^1].split('&')[0]
applySkin(skin)
skinsEl.addEventListener("click", proc (ev: Event) =
let k = dataOf(ev, "skin")
ifnot k.isNil: applySkin($k))
if fromUrl.contains("tutorial"):
tutorial = true
code = games[0][0]
applyLang()
# ---------------------------------------------------------------- the daily splash and the first lesson (src/splash.nim)block:
let p = decode(builtin[0][1])
var sol = ""for ch in answerKeys[0]: sol.add ch
nbRawHtml: splashHtml(p.letters, sol, p.givens.mapIt(idx(it.pos)), p.clues.mapIt((it.word, it.pos.row, it.pos.col)))
nbText: """
That's the game. Puzzles live in [`data/`](https://github.com/pietroppeter/parloku/tree/main/data) as puzzlepaw JSON,
and any puzzle can be played from a link such as `?p=AILPST_51S.23L.04P_00ITALIA.20PASTA.40ALPI`
(the 🔗 button copies it). Progress is kept in your browser, per puzzle.
The game speaks English and Italian (EN / IT at the top, or `?lang=it`), and `?tutorial` opens the 🎓 tutorial directly.
Built with [nimib](https://github.com/pietroppeter/nimib). Hit *show source* below for the whole notebook.
"""
nbSave