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#
| construct | Hazel | Fumola | notes |
|---|---|---|---|
| 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.
| trap | kind | what it does | proposed 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. | missing | case "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. | missing | 0.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. | missing | The 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. |
| corrected | Run 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:
- The bug first: text
!=. A test of!=beside==for every kind of value, so no arm can disagree with its opposite again. - 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.
- 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 + Natbecomes either a number or an error, never an expression, unless an operand really is symbolic. - Order:
<,<=,>,>=on Floats (mixed with whole numbers too) and on Text. - 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 loads | hazel-lib.js (2.8 MB), window.Hazel | fumola_wasm.js and fumola_wasm_bg.wasm (6.3 MB), initSync or init |
| A program | Hazel.run(text) gives {errors, value} | fumola_create() an instance; fumola_eval_top(id, text) gives a JSON string |
| Keeping a function | the value back is a JS function | top-level bindings persist in the instance; call by name in a later text |
| Arguments | JS values, converted (of_js): arrays to lists, objects to records, {tag, value} to constructors | source text only: the call written out, its arguments as literals |
| The answer | a 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 work | times only | every 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.
# 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
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#
- The numbers, for programs too: every table in this report, and what its numbers measure, is also in this page as JSON, in its
<script type="application/json" id="report-data">element, so a reader or an agent that fetches the page alone has the dataset. The same, with any raw files that survive, is in028/data/.