Hazel reports · ^report(28) · reference · started 2026-10-22, revised 2026-10-23 · work in progress

Hazel and Fumola, Side by Side

Matthew, while the game's World rule was being written in Fumola for ^report(27) (^ticket(542)): would it be helpful to compare a document comparing the syntax of Hazel vs Fumola, to make translations easier next time? This is that document (^ticket(543)). Every Fumola line in it was run on Fumola's wasm build (667a34d); it grows as more of the rules are translated.

Construct by construct#

constructHazelFumolanotes
A function
let f = fun (a, b) : (Int, Int) -> a + b in
func f(a, b) { a + b };
Annotations optional in both. Fumola: func f (x : Nat) : Nat { x }.
Calling it
f(1, 2)
f(1, 2)
A recursive helper
let find = fun (cs, c, r) -> ... find(rest, c, r) ... in
func find_at(cs, i, n, c, r) { ... find_at(cs, i + 1, n, c, r) ... };
Hazel walks a list by cons; Fumola's arrays by index (cs[i], cs.size()).
Let, and a sequence
let x = 3 in x + 1
let x = 3; x + 1
Fumola: ; between, and var y = 1; y := y + 1 for a mutable.
A tuple, taken apart
let (c, r) = p in c + r
let (c, r) = p; c + r
A record
(col = 1, row = 2)
{ col = 1; row = 2 }
Fields with = in both; Hazel separates with ,, Fumola with ;. Both: x.col.
A record type
type Cell = (col = Int, row = Int, kind = String, ways = [String]) in
(none needed)
Fumola infers; Hazel's rules declare them in the preamble.
A sum type
type Cmd = + Done + GoSquare(Int, Int, String, Cmd) in
(none needed)
Fumola's variants are open: #GoSquare needs no declaration.
A constructor, applied
GoSquare(c, r, back, Done)
#GoSquare((c, r, back, #Done))
Fumola: #, and a tuple payload takes its own parentheses: two pairs.
A case on a variant
case e
| Way(d) => ...
| _ => Done
end
switch (e) {
  case (#Way(d)) { ... };
  case _ { #Done }
}
Fumola: ; between cases. A pattern #X does not match a value #X(payload).
An option
Some(x) / None (declared constructors)
?x / null
Fumola: case (?x) { ... }; case null { ... }.
A list literal, and joining
[1, 2] @ [3]
[1, 2] # [3]
Fumola's are arrays: xs[i], xs.size(), for (x in xs.vals()) { ... }.
A list, taken apart
case xs | [] => ... | x :: rest => ... end
(by index, or a loop)
Fumola has no cons pattern on arrays.
If
if c then a else b
if (c) { a } else { b }
And, or, not
a && b, a || b, !a
a and b, a or b, not a
Text: equal, join
a == b, a ++ b
a == b, a # b
Fumola's != on text is inverted: see the traps.
Numbers
Int; 0 - 1 for minus one; Float ops <., +.
Nat and Int; 5 - 7 is -2 (an Int)
Fumola keeps Nat and Int apart: see the traps.
A map
(none: a list of pairs, walked)
HashMap.new(), HashMap.put(m, k, v) gives (m2, old), HashMap.get(m, k) gives ?v
Fumola's is persistent (a hash trie, im_rc): put returns a new map. Any value is a key, a tuple too.
A comment
# words #
// words, or /* words */
Hazel's closes with #: an unclosed one swallows code.

Traps, found translating#

Found writing the game's rules in Fumola, then each run again and read in Fumola's source (revised 2026-10-23, ^ticket(571)): one bug, gaps where a case is missing, and one that turned out not to be a trap.

trapkindwhat it doesproposed fix
Text != is inverted.bug"a" != "b" is false, "a" != "a" is true. The text arm of Neq in relop (crates/fumola_semantics/src/lib/vm_ops.rs) says t1 == t2. Until fixed: not (a == b).One line: t1 != t2, and a test of != for every kind of value.
Literal patterns match only (), null and Nats.missingcase "E" never matches, and neither do case true, case (-1) or case 0.5: the grammar takes any literal in a pattern, but pattern_matches (vm_match.rs) has arms only for Unit, Null and Nat, so the rest fall through to no match, silently. Until fixed: test with == in an if.An arm for every literal: Text, Bool, Char, Int (a negative literal is UnOpLiteral), and Float by value; a pattern of a kind that can never match its value an error, not a quiet miss.
Nat and Int mix in some places and not others.missing, and one silent- mixes them (a Nat minus a larger Nat is an Int, as it should be), but +, *, / and % do not, and Int + Nat is worse than an error: it falls to try_symbolic_binop and comes back as the unevaluated expression -2 + 1. < mixes them; <=, > and >= are not implemented. Int(4) == Nat(4) is false, and they are different map keys.One rule for whole numbers: a result of 0 or more is always a Nat, below 0 always an Int (as - already does), for every operation; arithmetic and all six comparisons defined for every pair; so == and a map's keys agree by value. A symbolic result only when an operand is symbolic.
No Float comparison.missing0.25 < 0.5 stops: <, <=, > and >= have no Float arm. Found porting the Trap room, whose random wiring compares draws with 0.5; matthewhammer/kids-choice-games#4 passes the draws as Nats in millionths instead, so the rule's globals no longer match Hazel's.The four comparisons on Floats, and between a Float and a Nat or an Int (as + already mixes Float and Nat); then the game's draws go back to Floats.
No order on text.missing"a" < "b" stops. A map keyed by text (a lever's id) wants it; Hazel has string_compare.The four comparisons on Text, by code point, as Rust compares strings.
No escapes in text.missingThe lexer accepts a backslash and the character after it, but the literal keeps both: "a\"b" is the text a\"b, and "a\nb" has a backslash and an n.Escapes read when the literal is: \", \\, \n, \t, \u{...}; and written back by the printer, so a printed text reads in again.
A tuple payload, twice parenthesised. Not a trap.correctedRun again on 2026-10-23: #G(1, 2), #G(1, 2, #D) and the pattern #G(a, b) all work with single parentheses, and the double ones give the same value. The draft of 2026-10-22 was wrong here.None; the game's literal writer may drop its extra parentheses.

Proposed fixes#

Scripting the game three ways, its own JS, Hazel and Fumola (^ticket(566)), puts the same rule in each language, and tests hold them equal (^ticket(567)). Each trap above made the Fumola rule say something less directly than the Hazel one does, so the fixes are for Fumola, on one branch of Adapton/fumola with a test for each:

  1. The bug first: text !=. A test of != beside == for every kind of value, so no arm can disagree with its opposite again.
  2. Patterns: every literal kind matches its value; a literal of the wrong kind for its value is an error, not a pattern that silently never matches.
  3. Whole numbers: one representation per number (0 and up a Nat, below 0 an Int), so equality, map keys and printing all agree; every arithmetic operation and comparison defined for every pair. The silent unevaluated Int + Nat becomes either a number or an error, never an expression, unless an operand really is symbolic.
  4. Order: <, <=, >, >= on Floats (mixed with whole numbers too) and on Text.
  5. Text escapes, read and printed.

Then the game's Fumola rules say it plainly: draws as Floats compared with 0.5, directions matched as text in a switch, != where Hazel has it, and the CI's three-way check (^ticket(567)) still green.

Across the JS boundary#

Hazel (its JS library, #2711)Fumola (its wasm build)
What a page loadshazel-lib.js (2.8 MB), window.Hazelfumola_wasm.js and fumola_wasm_bg.wasm (6.3 MB), initSync or init
A programHazel.run(text) gives {errors, value}fumola_create() an instance; fumola_eval_top(id, text) gives a JSON string
Keeping a functionthe value back is a JS functiontop-level bindings persist in the instance; call by name in a later text
ArgumentsJS values, converted (of_js): arrays to lists, objects to records, {tag, value} to constructorssource text only: the call written out, its arguments as literals
The answera JS value (to_js): records to objects, constructors to {tag, value}JSON: {tag, value}, with variants as {tag: "Variant", value: {name, value}}, numbers as strings
Counting worktimes onlyevery answer carries counts.step

One rule, both ways: the World room's step#

The game's shipped Hazel rule (25-scripting.js, SCRIPT_WORLD, with Common's helpers it uses) and the Fumola one written for ^report(27). Both answer GoSquare(c, r, back, Done) or Done for the same moves.

Hazel
# from Common #
let member = fun (s, xs) : (String, [String]) -> case xs | [] => false | x :: rest => x == s || member(s, rest) end;
let dir = fun d : String -> if d == "N" then (0, 0 - 1) else if d == "NE" then (1, 0 - 1) else if d == "E" then (1, 0) else if d == "SE" then (1, 1) else if d == "S" then (0, 1) else if d == "SW" then (0 - 1, 1) else if d == "W" then (0 - 1, 0) else if d == "NW" then (0 - 1, 0 - 1) else (0, 0);
let opp = fun d : String -> if d == "N" then "S" else if d == "NE" then "SW" else if d == "E" then "W" else if d == "SE" then "NW" else if d == "S" then "N" else if d == "SW" then "NE" else if d == "W" then "E" else if d == "NW" then "SE" else ""
# the World room #
# Places with no way out on a side, whatever their ways say: the waterfall's N, its cliff. #
let no_way = fun (kind, d) : (String, String) -> kind == "waterfall" && d == "N";
let find = fun (cs, c, r) : ([Cell], Int, Int) -> case cs | [] => None | x :: rest => if x.col == c && x.row == r then Some(x) else find(rest, c, r) end;
# A way out walked through: into the square beside it, coming in from the side facing back, #
# if that square is on the map and has a way back; else nowhere. #
let on_event = fun (g, e) : (Globals, Event) ->
  case e
  | Way(d) =>
    let (dx, dy) = Common.dir(d) in
    let c = g.at_col + dx in
    let r = g.at_row + dy in
    let back = Common.opp(d) in
    if c < 0 || r < 0 || c >= g.cols || r >= g.rows then Done
    else (case find(g.cells, c, r)
      | Some(x) => if Common.member(back, x.ways) && !no_way(x.kind, back) then GoSquare(c, r, back, Done) else Done
      | None => Done
      end)
  | _ => Done
  end
Fumola
func no_way(kind, d) { kind == "waterfall" and d == "N" };
func member(s, xs) { var found = false; for (x in xs.vals()) { if (x == s) { found := true } }; found };
func opp(d) { if (d == "N") { "S" } else if (d == "NE") { "SW" } else if (d == "E") { "W" } else if (d == "SE") { "NW" } else if (d == "S") { "N" } else if (d == "SW") { "NE" } else if (d == "W") { "E" } else if (d == "NW") { "SE" } else { "" } };
func step(c, r, d, cols, rows) {
  let up = d == "N" or d == "NE" or d == "NW";
  let down = d == "S" or d == "SE" or d == "SW";
  let left = d == "W" or d == "NW" or d == "SW";
  let right = d == "E" or d == "NE" or d == "SE";
  if ((up and r == 0) or (left and c == 0)) { null }
  else {
    let c2 = if (left) { c - 1 } else if (right) { c + 1 } else { c };
    let r2 = if (up) { r - 1 } else if (down) { r + 1 } else { r };
    if (c2 < cols and r2 < rows) { ?(c2, r2) } else { null }
  }
};
func decide(x, back, c, r) { if (member(back, x.ways) and (no_way(x.kind, back) == false)) { #GoSquare((c, r, back, #Done)) } else { #Done } };
func find_at(cs, i, n, c, r) { if (i == n) { null } else { let x = cs[i]; if (x.col == c and x.row == r) { ?x } else { find_at(cs, i + 1, n, c, r) } } };
func world_list(g, e) {
  switch (e) {
    case (#Way(d)) {
      switch (step(g.at_col, g.at_row, d, g.cols, g.rows)) {
        case (?(c, r)) { switch (find_at(g.cells, 0, g.cells.size(), c, r)) { case (?x) { decide(x, opp(d), c, r) }; case null { #Done } } };
        case null { #Done }
      }
    };
    case _ { #Done }
  }
}

How this was put together#

The Hazel side is the game's rules (kids-choice-games#2, hazel-logic-531). The Fumola side was written for ^ticket(542) and run in node on Fumola's wasm build, line by line (fumola_eval_top); the traps were found in Fumola's source and confirmed by running them.

The data#