Hazel reports · ^report(30) · game scripts · 2026-10-23 · work in progress · about 8 minutes to read
Game Scripts in Hazel, Two Days In
Matthew: a report about the last 2 days -- with an idea of where we are with our goals of game scripts in hazel, for MUDU.
The goal came out of his 2026-10-21 meeting with Cyrus Omar, who encouraged following the excitement: Hazel as a game script system, starting with Messed Up Dress Up (MUDU), as a general way to put Hazel code into a JS/HTML app's behavior. The two days since found some gaps in what the scripts can say, and mostly gaps in speed.
Short answers.
- The game's rules run in Hazel and in Fumola, beside its JS. Five rooms (the trap room, the tree houses, placed levers, the world's ways, the castle) and a 📚 Common module, written in both languages, each checked against the game's own JS: on three open pull requests to the game, behind a page-wide switch.
- Fumola is fast enough now; Hazel is, on trial branches. One World move at 64x64 (4,096 squares), on the same machines: Fumola with its HashMap 0.587 ms; Hazel with the game's own map 1.46 ms (2.5x). Both well inside a 60 fps frame (16.7 ms), and both flat from 3x3 to 64x64. Hazel's number needs this week's library fixes, which the library the game loads does not have, and the game holding the map. With the world passed in at every move, as the game calls its rules today, the move walks the whole world: 331 ms on the same build (Figure 1).
- Most of the two days went to Hazel's speed, and it paid. Functions keep their closures across the JS boundary; list loops no longer re-type the whole list each time; a big value can cross once, as a handle; values skip the evaluator's five stages (14,009 passes for
count(1000)become 9,007). Together, with a map, a World move went from 241 ms to 0.97 ms (Figure 2). All on trial branches, measured on GitHub, none in the game yet. - What is left for Hazel is the cost of each pass. Making the map, once a world, takes 1.5 to 2.1 s at 64x64 on GitHub's runners (2,136 ms against Fumola's 326 on the same ones): three walks of a list (two now) at Hazel's cost per evaluator pass, which pays for the editor's live features on every term (
^report(29)). - Next: the three-way script boxes. Each room's box a JS, Hazel or Fumola chip (the JS shown read-only, the game's own code; Hazel and Fumola to edit), a master chip, tests that keep the three equivalent, a tool for each script's overhead, and speed runs that hold each setting to a frame rate.
trial/r25-553 (the best build with this week's fixes 547, 548 and 551), the rule texts from the game's branch hazel-map-553; Fumola's wasm from 667a34d, ^report(27)'s driver. The same four GitHub runners for both, alternating, each side's better round, the median over the runners (run 39887272978).The goal, and where each piece is#
| the goal, piece by piece | where it is | |
|---|---|---|
| Hazel code in the game, one module per room plus 📚 Common | The 🧩 Scripting panel (matthewhammer/kids-choice-games#1: the trap room's levers through Hazel.runProgram, the program shown in Hazel's tiles), then the five rooms' rules in Hazel (matthewhammer/kids-choice-games#2), each under ?logic=hazel, checked against the JS. | built |
| Fumola code in the game | The same rules in Fumola, wasm, pinned build (matthewhammer/kids-choice-games#4), 20,653 cases agreeing with the Hazel rules in a node check. | built |
| A JS / Hazel / Fumola chip on each code box; the JS read-only, the game's own code; a master chip, JS by default | Today a page-wide ?logic= switch, Hazel and Fumola on separate PRs. ^ticket(566). | planned |
| The three equivalent, held by tests as the code changes | Node checks, Hazel against JS and Fumola against Hazel, in the two PRs; not all three in CI. ^ticket(567). | in part |
| A tool measuring each script's overhead over JS | The game records Hazel's compile time; the speed probes time rules. ^ticket(568). | planned |
| Speed-run standards: each setting a minimum fps where its scripts run | ^ticket(569). | planned |
| Hazel fast enough for every room at 60 fps | Levers, trap doors, the castle: yes. The world's ways: not as the game calls them today, the world passed in at every move (a call walks the whole world: 331 ms at 64x64 even on the trial build); yes with this week's library fixes and the map held (1.46 ms). The fixes are on trial branches, not in the library build the game loads, and the game does not hold the map yet. | waits |
| Fumola fast enough | Yes: about a millisecond a call, flat with its HashMap (0.587 ms at 64x64). | built |
What was built#
- The 🧩 Scripting panel (matthewhammer/kids-choice-games#1): one editor per room that has logic, and 📚 Common, the bodies of the modules of one Hazel program; Hazel builds the program (
Hazel.runProgram) and shows it in its own tiles, in a frame, with its error marks. A rule says what to do as data (Cmd:Flip("lever_L", Done),GoSquare(c, r, side, Done)), and the game does it. - The rooms' rules in Hazel (matthewhammer/kids-choice-games#2): the trap room's red and blue levers, the tree houses' trap doors, placed levers and trap doors, the world's ways, the castle's pits and ladders, under
?logic=hazel, every decision checked against the JS in the game's browser checks. Its speed runs (^report(25)): levers add 0.8 to 3.6 ms, a world way about 5.6 ms on the test map, a fall or a ladder hides behind the walk, and the program compiles in about 1.2 s at load. - The same rules in Fumola (matthewhammer/kids-choice-games#4): wasm, a pinned build, a node check of 20,653 cases against the Hazel rules; a rule call about 1.1 to 1.4 ms against Hazel's 2.9 on one runner, and Fumola sets up in 4 ms where Hazel compiles in 514 (
^report(27)). - The game's own map (
^ticket(553), branchhazel-map-553): 📚 Common gains a balanced search tree of the world's squares (squares,square,set_square; put in order first when the game sends them in another), and 🗺️ World a rule that looks one square up (with_squares) beside the shipped one that walks the list. Checked in every run: on a world of random kinds and ways, its cells shuffled, the two agree for every square and all eight directions (392 cases).
The rules, three ways#
Each room's rule as the game's 🧩 Scripting panel shows it (^ticket(566)): the game's own JS (read only, the functions that run), and the same rule in Hazel and in Fumola, the ones a player can edit. A chip on each box picks the language; the one at the top picks for every box. All three answer the same for every case the game can give them: tools/hazelrules531.mjs (32,761 cases) and tools/fumola_rules_check.mjs (20,649) check it on every push (^ticket(567)). Their vocabulary is sums, not text, as Languages of Play prescribes: directions, sides, wirings, square and room kinds. From matthewhammer/kids-choice-games, branch script-boxes-566 at 2f0e853ef3.
Fumola needs none of this: its variants (#N, #Waterfall) are not declared. The kinds are generated from the game's own lists (PLACES, ROOM_KINDS).
type Dir = + N + NE + E + SE + S + SW + W + NW in type Side = + L + R in type Wiring = + Normal + Reversed + Random in type Out = + Auto + OutOfCastle + Nowhere + ToRoom(Int) in type Place = + NoPlace + Emptyroom + Bubblespace + Bubbleroom + Imagination + Forest + Underwater + Deepsea + Beach + Sea + Witch + Cemetery + Lake + House + Farmyard + Treehouse + Treeroom + Treetop + Livingroom + Witchroom + Clearing + Healerroom + Fairyforest + Mountains + Waterfall + Campsite + Tent + Cave + Caveroom + Woodpath + Pasture + Crossing + Castle + Castleroom + Hut + Hutroom + Hutbed + Hutpotion + Hutcenter + Desert in type Room = + King + Greenhouse + Trap + Dungeon1 + Dungeon2 + Hallway + Catroom + Stable + Dining + Kitchen + Smithy + Spiral in type Exit = + ExitSide + ExitLong + ExitSpiral + ExitTrap + ExitLadder + ExitAll + ExitHall in type Cmd = + Done + Flip(Side, Cmd) + Say(String, Cmd) + SetPit(Int, Bool, Cmd) + DropBoulder(Int, Cmd) + LiftBoulder(Int, Cmd) + SetHatch(Int, Bool, Cmd) + OpenDoor(Int, Bool, Cmd) + DropRock(Int, Bool, Cmd) + Wait(Int, Cmd) + GoRoom(Int, Cmd) + GoSquare(Int, Int, Dir, Cmd) in type Event = + Pull(Side) + PullBoulder(Side) + PullHatch(Int) + PullThing(Int) + FallPit(Int) + WayOut(Int) + Way(Dir) in type Thing = (at = Int, uid = String, kind = String, on = Bool, works = [String], does = String, open = Bool, boulder = Bool) in type Cell = (col = Int, row = Int, kind = Place, ways = [Dir]) in type Globals = (lever_L = Bool, lever_R = Bool, wiring = Wiring, draws = [Float], pits_on = [Bool], covered = [Bool], hatch = Bool, things = [Thing], rooms = [Room], exits = [Exit], out = Out, at_col = Int, at_row = Int, cols = Int, rows = Int, cells = [Cell]) in
// The game's JS has no common module: each room's functions carry what they need.# Sub-routines every room can use, as Common.wire and so on. # let flip2 = fun (a, b) : (Side, Side) -> Flip(a, Flip(b, Done)); # the i-th of a list of Bools, false past its end # let nth = fun (xs, i) : ([Bool], Int) -> case xs | [] => false | x :: rest => if i == 0 then x else nth(rest, i - 1) end; # which lever works each pit, the left and the right: normal, reversed, or at random by the game's two draws # let wire = fun (mode, draws) : (Wiring, [Float]) -> case mode | Reversed => (R, L) | Random => (case draws | a :: b :: _ => (if a <. 0.5 then L else R, if b <. 0.5 then L else R) | _ => (L, R) end) | Normal => (L, R) end; # a red lever up? (true is up) # let up = fun (l, r, side) : (Bool, Bool, Side) -> case side | L => l | R => r end; # each pit open: open when the lever working it is up # let pits_open = fun (l, r, w) : (Bool, Bool, (Side, Side)) -> let (w0, w1) = w in (up(l, r, w0), up(l, r, w1)); # a pit that changed from was to now: SetPit, else nothing # let pit_moves = fun (i, was, now, k) : (Int, Bool, Bool, Cmd) -> if was == now then k else SetPit(i, now, k); let member = fun (s, xs) : (String, [String]) -> case xs | [] => false | x :: rest => x == s || member(s, rest) end; # a way among a square's ways # let has_way = fun (d, ds) : (Dir, [Dir]) -> case ds | [] => false | x :: rest => x == d || has_way(d, rest) end; # where room kind k first is in a castle's rooms, counting from 1; 0 when it is not there # let room_at = fun (rs, k) : ([Room], Room) -> case rs | [] => 0 | x :: rest => if x == k then 1 else (let i = room_at(rest, k) in if i == 0 then 0 else i + 1) end; # the i-th room's exits, counting from 1; none past the end # let exit_of = fun (xs, i) : ([Exit], Int) -> case xs | [] => None | x :: rest => if i == 1 then Some(x) else exit_of(rest, i - 1) end; let rooms = fun rs : [Room] -> case rs | [] => 0 | _ :: rest => 1 + rooms(rest) end; # the world's eight directions: each one's step across and down the map, and the way back # let dir = fun d : Dir -> case d | N => (0, 0 - 1) | NE => (1, 0 - 1) | E => (1, 0) | SE => (1, 1) | S => (0, 1) | SW => (0 - 1, 1) | W => (0 - 1, 0) | NW => (0 - 1, 0 - 1) end; let opp = fun d : Dir -> case d | N => S | NE => SW | E => W | SE => NW | S => N | SW => NE | W => E | NW => SE end
module Common { // two levers flipped, one after the other public func flip2(a, b) { #Flip(a, #Flip(b, #Done)) }; // the i-th of a list of Bools, false past its end public func nth(xs, i) { if (i < xs.size()) { xs[i] } else { false } }; // which lever works each pit, the left and the right: normal, reversed, or at random by the game's two draws public func wire(mode, draws) { switch (mode) { case (#Reversed) { (#R, #L) }; case (#Random) { if (draws.size() < 2) { (#L, #R) } else { (if (draws[0] < 0.5) { #L } else { #R }, if (draws[1] < 0.5) { #L } else { #R }) } }; case _ { (#L, #R) } } }; // a red lever up? (true is up) public func up(l, r, side) { switch (side) { case (#L) { l }; case _ { r } } }; // each pit open: open when the lever working it is up public func pits_open(l, r, w) { let (w0, w1) = w; (up(l, r, w0), up(l, r, w1)) }; // a pit that changed from was to now: SetPit, else nothing public func pit_moves(i, was, now, k) { if (was == now) { k } else { #SetPit(i, now, k) } }; public func member(s, xs) { var found = false; for (x in xs.vals()) { if (x == s) { found := true } }; found }; // a way among a square's ways public func has_way(d, ds) { var found = false; for (x in ds.vals()) { if (x == d) { found := true } }; found }; // where room kind k first is in a castle's rooms, counting from 1; 0 when it is not there public func room_at(rs, k) { var i = 0; var at = 0; for (x in rs.vals()) { i := i + 1; if (at == 0 and x == k) { at := i } }; at }; // the i-th room's exits, counting from 1; null past the end public func exit_of(xs, i) { if (i >= 1 and i <= xs.size()) { ?(xs[i - 1]) } else { null } }; public func rooms(rs) { rs.size() }; // the world's eight directions: each one's step across and down the map, and the way back public func dir(d) { switch (d) { case (#N) { (0, -1) }; case (#NE) { (1, -1) }; case (#E) { (1, 0) }; case (#SE) { (1, 1) }; case (#S) { (0, 1) }; case (#SW) { (-1, 1) }; case (#W) { (-1, 0) }; case _ { (-1, -1) } } }; public func opp(d) { switch (d) { case (#N) { #S }; case (#NE) { #SW }; case (#E) { #W }; case (#SE) { #NW }; case (#S) { #N }; case (#SW) { #NE }; case (#W) { #E }; case _ { #SE } } } }
// 26-drags-and-start.js: roomLeverPull function roomLeverPull(side) { if (side[0] === "B") return roomBoulderPull(side.slice(1)); // (ticket 323: a blue one) if (side === "H") return hatchLeverPull(); // (ticket 334: a tree room's trap door's) if (scriptEvent("TrapRoom", { tag: "Pull", value: side })) return; // (ticket 482: 🧩 Scripting's rule for the trap room, when it is on) hzJs("Pull", () => roomLeverFlip(side)); // (ticket 531: timed as the rule is) } // 26-drags-and-start.js: roomLeverFlip function roomLeverFlip(side, lids = true) { const B = state.background; if (B.kind !== "castleroom") return; const K = castleOf(B), n = Math.max(1, Math.min(K.rooms.length, Math.round(B.castleRoom ?? 1))), key = leverKey(side, n, K.id); const was = trapState(B, n, K.id).open; B[key] = !leverDown(B, side, n, K.id); // (ticket 307: never touched, it was down) changed(); const up = !B[key], sd = side === "L" ? -1 : 1, T = trapState(B, n, K.id), now = T.open; turnArm(`[data-lever="${side}"] .lever-arm`, (up ? 1 : -1) * sd * LEVER_TILT, LEVER_TURN * 100 / T.speed.lever); // (ticket 328: the room's speeds) if (!lids) return; for (const j of [0, 1]) if (now[j] !== was[j]) lidSwing(`.pit-lid[data-pit-i="${j}"]`, now[j], PIT_SWING * 100 / T.speed.lid); // (ticket 307) // (Matthew: "opened by the red lever (trap door) while standing on it, and no falling sequence was triggered") a pit opened // under the friend: in they fall, as walking onto it does, once its lid has swung most of the way if (now.some((o, j) => o && !was[j])) setTimeout(fallIfOverPit, 0.6 * PIT_SWING * 100 / T.speed.lid); } // 26-drags-and-start.js: roomBoulderPull function roomBoulderPull(side) { if (scriptEvent("TrapRoom", { tag: "PullBoulder", value: side })) return; // (ticket 531: 🧩 TrapRoom's rule, when it is on) hzJs("PullBoulder", () => { const B = state.background; if (B.kind !== "castleroom") return; const K = castleOf(B), n = Math.max(1, Math.min(K.rooms.length, Math.round(B.castleRoom ?? 1))); // (ticket 338) the pits this lever works: a fallen rock still on any, lifted away (each rising, and gone from the place's // things); else into each a new rock falls, which once landed is a placed rock, "fallen N", dragged where one likes const T0 = trapState(B, n, K.id), mine = [0, 1].filter(j => T0.wire[j] === side && T0.on[j]), lift = mine.some(j => T0.covered[j]); roomBoulderAct(side, mine, lift); }); }
# A red lever pulled: flip it, and each pit wired to it opens or shuts. # # A blue lever pulled: each of its pits gets a boulder, or loses one. # let on_event = fun (g, e) : (Globals, Event) -> let w = Common.wire(g.wiring, g.draws) in let (w0, w1) = w in case e | Pull(side) => let (was0, was1) = Common.pits_open(g.lever_L, g.lever_R, w) in let l = if side == L then !g.lever_L else g.lever_L in let r = if side == R then !g.lever_R else g.lever_R in let (now0, now1) = Common.pits_open(l, r, w) in Flip(side, Common.pit_moves(0, was0, now0, Common.pit_moves(1, was1, now1, Done))) | PullBoulder(side) => let mine0 = w0 == side && Common.nth(g.pits_on, 0) in let mine1 = w1 == side && Common.nth(g.pits_on, 1) in let lift = (mine0 && Common.nth(g.covered, 0)) || (mine1 && Common.nth(g.covered, 1)) in let one = fun (i, mine, k) : (Int, Bool, Cmd) -> if !mine then k else if lift then LiftBoulder(i, k) else DropBoulder(i, k) in one(0, mine0, one(1, mine1, Done)) | _ => Done end
module TrapRoom { // a blue lever's pit i: nothing if not its own, else its rock lifted or a rock dropped func one(i, mine, lift, k) { if (not mine) { k } else if (lift) { #LiftBoulder(i, k) } else { #DropBoulder(i, k) } }; // A red lever pulled: flip it, and each pit wired to it opens or shuts. // A blue lever pulled: each of its pits gets a boulder, or loses one. public func on_event(g, e) { let w = Common.wire(g.wiring, g.draws); let (w0, w1) = w; switch (e) { case (#Pull(side)) { let (was0, was1) = Common.pits_open(g.lever_L, g.lever_R, w); let l = if (side == #L) { not g.lever_L } else { g.lever_L }; let r = if (side == #R) { not g.lever_R } else { g.lever_R }; let (now0, now1) = Common.pits_open(l, r, w); #Flip(side, Common.pit_moves(0, was0, now0, Common.pit_moves(1, was1, now1, #Done))) }; case (#PullBoulder(side)) { let mine0 = (w0 == side) and Common.nth(g.pits_on, 0); let mine1 = (w1 == side) and Common.nth(g.pits_on, 1); let lift = (mine0 and Common.nth(g.covered, 0)) or (mine1 and Common.nth(g.covered, 1)); one(0, mine0, lift, one(1, mine1, lift, #Done)) }; case _ { #Done } } } }
// 26-drags-and-start.js: hatchLeverPull function hatchLeverPull() { const B = state.background; // (ticket 516: and the round room at the top of the tree's, on its post) if (B.kind !== "treeroom" && B.kind !== "treetop") return; if (scriptEvent("TreeRoom", { tag: "PullHatch", value: hatchHouse() })) return; // (ticket 531: 🧩 TreeRoom's rule, when it is on) hzJs("PullHatch", () => hatchSet(!B[hatchKey()])); } // 26-drags-and-start.js: hatchSet function hatchSet(shut) { const B = state.background, key = hatchKey(); B[key] = shut; changed(); const open = !B[key]; turnArm(`[data-lever="H"] .lever-arm`, (open ? 1 : -1) * LEVER_TILT); lidSwing(`.pit-lid[data-pit-i="hatch"]`, open); if (open) setTimeout(() => { solidUnstick(200); changed(); }, 0); }
# A tree room's trap-door lever, or the round room's at the top (house 0): # # the trap door shuts if open, opens if shut. # let on_event = fun (g, e) : (Globals, Event) -> case e | PullHatch(n) => SetHatch(n, !g.hatch, Done) | _ => Done end
module TreeRoom { // A tree room's trap-door lever, or the round room's at the top (house 0): // the trap door shuts if open, opens if shut. public func on_event(g, e) { switch (e) { case (#PullHatch(n)) { #SetHatch(n, not g.hatch, #Done) }; case _ { #Done } } } }
// 26-drags-and-start.js: leverPull function leverPull(i) { const T0 = (state.background.things || [])[i]; if (!T0 || T0.kind !== "lever") return; if (scriptEvent("Square", { tag: "PullThing", value: i })) return; // (ticket 531: 🧩 Square's rule, when it is on) hzJs("PullThing", () => { const B = state.background, T = B.things[i], doors = []; // (ticket 323: a boulder lever drops a boulder into each of its trap doors as it goes over, and lifts it out as it comes back) for (const d of B.things.entries()) { const [j, t] = d; if (t.kind !== "trapdoor" || !(T.works || []).includes(t.uid)) continue; if (T.does === "boulder") { if (!!t.boulder !== !T.on) doors.push([j, "boulder", !T.on]); } else doors.push([j, "open", !t.open]); } leverPullApply(i, doors); }); } // 26-drags-and-start.js: leverPullApply function leverPullApply(i, doors) { const B = state.background, T = (B.things || [])[i]; if (!T || T.kind !== "lever") return; T.on = !T.on; const swung = [], dropped = [], seed = 1 + Math.floor(Math.random() * 999999); for (const [j, what, v] of doors) { const d = B.things[j]; if (!d || d.kind !== "trapdoor") continue; if (what === "boulder") { d.boulder = v; d.rock = T.rock || ROCK_TONES[0]; if (v) d.rockSeed = seed + j; dropped.push([j, v]); } else { d.open = v; swung.push([j, v]); } } changed(); turnArm(`[data-drag="thing:${i}"] .lever-arm`, T.on ? LEVER_TILT : -LEVER_TILT); for (const [j, open] of swung) lidSwing(`[data-drag="thing:${j}"] .pit-lid`, open); // (ticket 307) // (a boulder lifted out is drawn no more once it has gone: drawn again, held, for its rising; ticket 323) for (const [j, down] of dropped) if (down) boulderFall(`[data-drag="thing:${j}"] [data-boulder]`, true, BOULDER_FALL, boulderHits, null, boulderFalling); // (a placed boulder lever lifting its boulders: sparkles from the lever and from each trap door they leave) if (T.does === "boulder" && !T.on) { sparkleFrom(`[data-drag="thing:${i}"] .lever-arm circle`, 14); for (const [j] of dropped) sparkleFrom(`[data-drag="thing:${j}"]`, 22); } }
# A placed lever pulled: each trap door wired to it swings, # # or, a boulder lever, gets its rock or loses it. # let on_event = fun (g, e) : (Globals, Event) -> case e | PullThing(i) => let find = fun ts : [Thing] -> case ts | [] => None | t :: rest => if t.at == i then Some(t) else find(rest) end in let doors = fun (lever, ts) : (Thing, [Thing]) -> case ts | [] => Done | d :: rest => let k = doors(lever, rest) in if !(d.kind == "trapdoor" && Common.member(d.uid, lever.works)) then k else if lever.does == "boulder" then (if d.boulder == !lever.on then k else DropRock(d.at, !lever.on, k)) else OpenDoor(d.at, !d.open, k) end in (case find(g.things) | Some(t) => doors(t, g.things) | None => Done end) | _ => Done end
module Square { // the first thing at place i, from the j-th on func find(ts, j, i) { if (j == ts.size()) { null } else if (ts[j].at == i) { ?(ts[j]) } else { find(ts, j + 1, i) } }; // each trap door the lever works, from the j-th thing on, in order: it swings, or, a boulder lever, gets its rock or loses it func doors(lever, ts, j) { if (j == ts.size()) { #Done } else { let d = ts[j]; let k = doors(lever, ts, j + 1); if (not (d.kind == "trapdoor" and Common.member(d.uid, lever.works))) { k } else if (lever.does == "boulder") { if (d.boulder == (not lever.on)) { k } else { #DropRock(d.at, not lever.on, k) } } else { #OpenDoor(d.at, not d.open, k) } } }; // A placed lever pulled: each trap door wired to it swings, // or, a boulder lever, gets its rock or loses it. public func on_event(g, e) { switch (e) { case (#PullThing(i)) { switch (find(g.things, 0, i)) { case (?t) { doors(t, g.things, 0) }; case null { #Done } } }; case _ { #Done } } } }
// 25-world.js: worldTarget function worldTarget(W, key, d) { const cell = W && W.cells[key], L = cell && cell.links && cell.links[d]; if (L) { const T = worldById(L.world), c = T && T.cells[L.key]; return c ? { W: T, key: L.key, cell: c, link: true } : null; } const m = worldMeets(W, key, d); return m ? { W, key: m, cell: W.cells[m], link: false } : null; } // 25-world.js: worldMeets function worldMeets(W, key, d) { const nb = worldNeighbour(W, key, d), other = W.cells[nb.key]; return !nb.off && other && worldWays(other).includes(WORLD_OPP[d]) ? nb.key : null; }
# Places with no way out on a side, whatever their ways say: the waterfall's N, its cliff. # let no_way = fun (kind, d) : (Place, Dir) -> 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.has_way(back, x.ways) && !no_way(x.kind, back) then GoSquare(c, r, back, Done) else Done | None => Done end) | _ => Done end
var world_held = HashMap.new(); module World { // Places with no way out on a side, whatever their ways say: the waterfall's N, its cliff. func no_way(kind, d) { kind == #Waterfall and d == #N }; // into square (c, r), come in from back, if it has a way back; else nowhere func decide(x, back, c, r) { if (Common.has_way(back, x.ways) and not (no_way(x.kind, back))) { #GoSquare(c, r, back, #Done) } else { #Done } }; func find_at(cs, i, c, r) { if (i == cs.size()) { null } else { let x = cs[i]; if (x.col == c and x.row == r) { ?x } else { find_at(cs, i + 1, c, r) } } }; // the square a way leads to, if it is on the map: (c, r), or null func step(ac, ar, d, cols, rows) { let (dx, dy) = Common.dir(d); let c = ac + dx; let r = ar + dy; if (c < 0 or r < 0 or c >= cols or r >= rows) { null } else { ?(c, r) } }; // 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. public func on_event(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, c, r)) { case (?x) { decide(x, Common.opp(d), c, r) }; case null { #Done } } }; case null { #Done } } }; case _ { #Done } } }; // the same, the square found in the map held here public func way(ac, ar, cols, rows, d) { switch (step(ac, ar, d, cols, rows)) { case (?(c, r)) { switch (HashMap.get(world_held, (c, r))) { case (?x) { decide(x, Common.opp(d), c, r) }; case null { #Done } } }; case null { #Done } } }; // the map held here made again from the game's cells public func hold(cells) { var m = HashMap.new(); for (x in cells.vals()) { let (m2, _) = HashMap.put(m, (x.col, x.row), x); m := m2 }; world_held := m; cells.size() } }
// 13-castle-rooms-and-map.js: castleroomScene // in castleroomScene(), the lines that decide it: // where a trap room's pits lead: the castle's dungeons (the first, then the deep one) const d1 = K.rooms.indexOf("dungeon1") + 1, d2 = K.rooms.indexOf("dungeon2") + 1; const pits = [d1 || d2, d2 || d1]; // 13-castle-rooms-and-map.js: wayOutOf function wayOutOf(K, n) { const st = K.rooms[n - 1], S = ROOM_S_OUT.includes(ROOM_KINDS[st].exits); // (ticket 233: nowhere only where the way out is toward us; a room changed to an arch's or a ladder's, the usual way) const v = K.hall[n - 1].out === "none" && !S ? "auto" : K.hall[n - 1].out; if (v === "auto") { const trapAt = K.rooms.indexOf("trap") + 1; return ROOM_KINDS[st].exits === "ladder" && trapAt && trapAt !== n ? trapAt : n === 1 ? 0 : 1; } if (v === "out") return 0; if (v === "none") return null; // (ticket 233: a way toward us may lead nowhere) const r = Number(v); return r >= 1 && r <= K.rooms.length ? r : null; } // 25-scripting.js: scriptGo function scriptGo(door) { const B = state.background, pit = door.dataset.pit; if (B.kind !== "castleroom" || (!pit && door.dataset.to !== "castle" && door.dataset.to !== "castleroom")) return null; if (!pit) { const K = castleOf(B); if (ROOM_KINDS[K.rooms[castleRoomNow(B, K) - 1]].exits !== "ladder") return null; } // (a pit, by its side: the left's middle is left of the room's; else a door, which in a dungeon is its ladder up) const ev = pit ? { tag: "FallPit", value: +pit.split(" ")[0] < 0 ? 0 : 1 } : { tag: "WayOut", value: castleRoomNow(B) }; if (!scriptEvent("Castle", ev)) { hzJs(ev.tag, () => door.dataset.room); return null; } if (!scriptEvent.act) return null; // (the rule stopped: the door's own way, not counted as a mismatch) const got = scriptEvent.act.got, r = got && got.go, go = r == null || r < 0 ? false : r === 0 ? { to: "castle", room: undefined } : { to: "castleroom", room: String(r) }; if ((go ? go.to : null) !== door.dataset.to || (go ? go.room : undefined) !== door.dataset.room) hzCounters().mismatch++; return go; }
# Where room n's way out leads: 0 out of the castle, a room, or -1 nowhere. # # out is its setting; Auto: a dungeon's ladder up to the trap room, room 1 out, any other to room 1. # # Nowhere only where the way out is toward us (side doors, a long room's, a spiral's); elsewhere Auto. # let way_out = fun (rooms, exits, n, out) : ([Room], [Exit], Int, Out) -> let ex = Common.exit_of(exits, n) in let toward_us = (case ex | Some(ExitSide) => true | Some(ExitLong) => true | Some(ExitSpiral) => true | _ => false end) in let ladder = (case ex | Some(ExitLadder) => true | _ => false end) in case (case out | Nowhere => (if toward_us then Nowhere else Auto) | o => o end) | Auto => (let t = Common.room_at(rooms, Trap) in if ladder && t > 0 && !(t == n) then t else if n == 1 then 0 else 1) | OutOfCastle => 0 | Nowhere => 0 - 1 | ToRoom(r) => if r >= 1 && r <= Common.rooms(rooms) then r else 0 - 1 end; # A trap room's pit fallen into (0 the left, 1 the right): # # the first pit drops into the dungeon, the second into the deep dungeon; # # a castle with only one of them, both into that one. # let on_event = fun (g, e) : (Globals, Event) -> let d1 = Common.room_at(g.rooms, Dungeon1) in let d2 = Common.room_at(g.rooms, Dungeon2) in case e | FallPit(i) => let to = if i == 0 then (if d1 > 0 then d1 else d2) else (if d2 > 0 then d2 else d1) in if to == 0 then Done else GoRoom(to, Done) | WayOut(n) => GoRoom(way_out(g.rooms, g.exits, n, g.out), Done) | _ => Done end
module Castle { // Where room n's way out leads: 0 out of the castle, a room, or -1 nowhere. // out is its setting; Auto: a dungeon's ladder up to the trap room, room 1 out, any other to room 1. // Nowhere only where the way out is toward us (side doors, a long room's, a spiral's); elsewhere Auto. public func way_out(rooms, exits, n, out) { let ex = Common.exit_of(exits, n); let toward_us = ex == ?(#ExitSide) or ex == ?(#ExitLong) or ex == ?(#ExitSpiral); let v = if (out == #Nowhere and not toward_us) { #Auto } else { out }; switch (v) { case (#Auto) { let t = Common.room_at(rooms, #Trap); if (ex == ?(#ExitLadder) and t > 0 and t != n) { t } else if (n == 1) { 0 } else { 1 } }; case (#OutOfCastle) { 0 }; case (#Nowhere) { -1 }; case (#ToRoom(r)) { if (r >= 1 and r <= Common.rooms(rooms)) { r } else { -1 } }; case _ { -1 } } }; // A trap room's pit fallen into (0 the left, 1 the right): // the first pit drops into the dungeon, the second into the deep dungeon; // a castle with only one of them, both into that one. public func on_event(g, e) { let d1 = Common.room_at(g.rooms, #Dungeon1); let d2 = Common.room_at(g.rooms, #Dungeon2); switch (e) { case (#FallPit(i)) { let to = if (i == 0) { if (d1 > 0) { d1 } else { d2 } } else { if (d2 > 0) { d2 } else { d1 } }; if (to == 0) { #Done } else { #GoRoom(to, #Done) } }; case (#WayOut(n)) { #GoRoom(way_out(g.rooms, g.exits, n, g.out), #Done) }; case _ { #Done } } } }
Languages of Play#
Languages of Play: Towards semantic foundations for game interfaces (Chris Martens and Matthew A. Hammer, 2017, arXiv:1703.05410) treats what a player can say to a game as a language of its own, a player intent language, and reads a game through programming-language theory, in the slogan "games as programming languages". Its running example is a small game of moving between rooms and taking things, under five interfaces (point-and-click, bird's-eye, WASD, command line, hypertext), and its Table 1 pairs each part of a language definition with a part of a game. The game's rules, written three ways, line up with that table closely:
| the paper | its game concept | in Messed Up Dress Up's rules |
|---|---|---|
| syntax | recognized player intents: direction ::= north | south | east | west, intent ::= move⟨direction⟩ | collect | the Event sum: Pull(Side), PullBoulder(Side), PullHatch(Int), PullThing(Int), FallPit(Int), WayOut(Int), Way(Dir), with Dir = + N + NE + E + SE + S + SW + W + NW. Taking a way out of a square, by whichever control, goes through one door handler (worldWalk) and reaches the rule as the one intent Way(d), as the paper's five interfaces all end in move⟨direction⟩. |
| operational semantics | game mechanics: a game step ⟨G₁; intent⟩ ⟶ ⟨G₂; resp⟩ | a room's rule, on_event : (Globals, Event) -> Cmd: the state it is told (Globals) and the intent give a response as data, which the game then carries out (its new state). Done alone is the paper's failure, a way that leads nowhere; GoSquare(c, r, side, Done) its success. |
| type system | contextual interface: only the intents that make sense in this state | the sums themselves: before them, a direction was any text, and a rule could be asked about "up" (the Fumola check had a case for it); now it cannot be said. The paper calls this the subtractive side of an interface's syntax. What is offered in a given state (a lever only where there is one) is still the game's JS, not a type. |
| a completeness conjecture: every state and intent has a next state and a response | proved, in a PL, by cases | checked, not proved: on every push, every case the game can give each room (32,761 for Hazel, 20,649 for Fumola) has an answer, and the same answer in all three languages (^ticket(567)). |
| play traces as straight-line programs | a log of intents and responses, as structured terms | a response is already a little straight-line program, each command with the rest after it (Flip(L, SetPit(0, true, Done))), which the game runs in order; a log of Events and Cmds would be the paper's trace, not kept yet. |
| player skills as general programs | players composing actions into their own programs (BOTS, Cube Composer) | the editable Hazel and Fumola boxes: a player (or M and N) can rewrite a room's rule in the game and play it at once, so the mechanics, not only the intents, become a language players write in. |
Where the game goes beyond the paper. The paper's languages are mathematical definitions. Here the mechanics are programs in two real languages beside the JS that defines them, held equal by tests, so one game has three semantics for the same intent language, and the 🧩 panel lets a player see and change which one runs. It also gives the paper's link to Hazelnut (an editor whose edits keep a program meaningful, cited there as the model for editing languages) a concrete place: the Hazel boxes are shown in Hazel's own tiles.
Where it does not yet. The game's interfaces (keys, the on-screen controller, clicks) are not themselves written as languages, as the paper's five are; the game's state crosses into the rules as a record of text and numbers made by its JS, not as typed terms from the start; and Hazel's sums could not yet live in the 📚 Common module, where the game's vocabulary belongs, until hazelgrove/hazel#2729 (a module's sum types usable after it), which came out of this.
Speed: what was found, and fixed#
Why a Hazel rule cost about 40 ms on a 16x16 world when the JS took 0.003 (^report(25)), why Fumola runs the same rule 2.4 to 10 times faster (^report(29)), and the fixes, each on a trial branch built by GitHub:
- The boundary's floor (
^ticket(539), branchlib-floor): a call's argument bound in the environment, not written into the call and stepped back into a value node by node. - Closures kept whole (
^ticket(547), real branchlib-closures,^ticket(558)): a function the library returns keeps its environment; it used to have its captured values written into its body, so a held list was evaluated again, part by part, at every call. One reference to a held list of 3,000: 77x faster. A CI test now holds it (^ticket(552): a call's evaluator passes are the same for a held list of 100 and of 10,000; without 547 the test fails). - List loops linear (
^ticket(548)): a loop passing a growing list on pushed its type over every element each time, with a fresh id for each; building and summing 3,000 numbers, 8.9x faster. - Handles (
^ticket(551)):Hazel.value(x)converts a JS value once; a call given the handle takes it as it is. - A fast path for values (
^ticket(559)): with nothing probed, reused or published (the library's calls, a game's rules), a term already a value comes back at once instead of passing through the evaluator's five stages; the step count reads the same and the evaluator's tests pass unchanged. A third fewer passes on loops, about a tenth of their time:
| program | n | passes | passes, fast path | time, fast path / without |
|---|---|---|---|---|
count | 1,000 | 14,009 | 9,007 | 0.92 |
fib | 16 | 46,294 | 33,525 | 0.90 |
h_walk | 3,000 | 39,012 | 27,010 | 0.98 |
h_ref | 3,000 | 3,008 | 7 | 0.03 |
sumbuild | 3,000 | 93,022 | 72,019 | 0.98 |
Passes from the counter build and the fast path on it, exact; times from trial/r25-best and the same fast path on it, four GitHub runners, alternating, the median (run 39885788957). h_ref is a reference to a held list, on a build without 547: skipped whole now.
^report(27)'s build (trial/r25-best) and the same with 547, 548 and 551 (trial/r25-553), the world passed in, as a handle, and held in the game's map. Holding the map pays only with 547; without it, the map is re-walked at every call and is slower than the list. Four GitHub runners, alternating (run 39886598556).The map against Fumola's, by size#
| world (squares) | Hazel, passed | Hazel, map | Fumola, passed | Fumola, HashMap | Hazel, map's set-up | Fumola, set-up |
|---|---|---|---|---|---|---|
| 3x3 (9) | 1.25 | 0.82 | 1.23 | 0.638 | 4.18 | 39.1 |
| 4x4 (16) | 1.78 | 1.06 | 1.42 | 0.601 | 7.48 | 25.7 |
| 6x6 (36) | 3.9 | 1.18 | 2.23 | 0.602 | 17.2 | 26.3 |
| 8x8 (64) | 5.69 | 1.15 | 3.36 | 0.603 | 29.1 | 28.5 |
| 12x12 (144) | 11.6 | 1.54 | 6.32 | 0.588 | 67.2 | 33.9 |
| 16x16 (256) | 19.5 | 1.23 | 10.5 | 0.593 | 120 | 41.5 |
| 24x24 (576) | 43.9 | 1.65 | 22.2 | 0.584 | 277 | 65.7 |
| 32x32 (1,024) | 79.5 | 1.34 | 39.1 | 0.595 | 518 | 94.1 |
| 48x48 (2,304) | 182 | 2.03 | 89.6 | 0.587 | 1,180 | 189 |
| 64x64 (4,096) | 331 | 1.46 | 169 | 0.587 | 2,136 | 326 |
ms, the same runners for both (run 39887272978: AMD EPYC 7763 64-Core Processor; AMD EPYC 9V74 80-Core Processor).
The map's set-up#
Once a world, 1,539 ms at 64x64 on the trial build, measured in parts: putting the squares in order and checking it (662), counting them (286), building the tree (927); bringing the cells over from JS is 77.7. Taking the type annotations off the map's helpers changes nothing (1,636). Each part is a walk of the list at Hazel's cost per pass, about 60 to 70 µs a square even for the count; doing the count in the same walk as the order check saved 15% (1,946 to 1,657 ms; runs 39888318843, 39890490518).
What the scripts can say#
- Ordering strings: a map keyed by a lever's id (a string) needs strings in order. Hazel has
string_compare, returningOrd(Lt,Eq,Gt), since May (Alexander Bandukwala,d5cd0a07a3, besideint_compareandfloat_compare). Corrected here: our ticket had said Hazel had none, from a search of one list of builtins. - Fumola's values from JS: JSON read straight into Fumola values (Adapton/fumola#148, merged), so a map passed in is no longer written out and parsed; and an answer more than about a thousand values deep no longer traps the wasm instance (Adapton/fumola#150, open). Fumola has no float comparison the rules can use yet (the game's draws are passed as whole millionths).
What next#
- The three-way script boxes (
^ticket(566)): one branch with the Hazel and the Fumola rules, a JS / Hazel / Fumola chip on each room's box (JS read-only: the source of the function that runs), editable Hazel and Fumola boxes, and a master chip at the top, JS by default. - Equivalence in CI (
^ticket(567)): for every handler and event, the shipped Hazel and Fumola texts answer as the JS does, on generated worlds. - A 🔬 tool for each script's overhead over JS (
^ticket(568)), per handler and per engine, live in the game. - Speed standards per setting (
^ticket(569)): speed runs for JS, Hazel and Fumola that hold the rooms each controls to a minimum frame rate. - The library fixes into the build the game loads: 547 for real (
^ticket(558)), then 548, 551 and the fast path; until then Hazel's world ways miss the frame on big maps, and the game cannot hold the map. - Hold the map in the game: call
with_squaresonce a world, not the list rule every way; then the placed levers' rule with its things in maps (by place, and by id withstring_compare). - Hazel's cost per pass (
^ticket(554), for Cyrus and Matthew): what to do about a step paying for the editor around it; it is what the map's set-up, and every loop, still pays.
How this was put together#
From the work tickets of 2026-10-21 11:00 to 2026-10-23 07:00 and the reports they produced (^report(25), ^report(27), ^report(28), ^report(29)). The new measurements ran on GitHub's runners, never the laptop: library builds deployed by hazelgrove/hazel's own workflow (no compiling), timed in node 22 by drivers on orphan branches (timing/553-map, timing/559-fastpath in hazelgrove/hazel), four jobs each, the builds compared alternating on one runner, each side's better round, the median over the jobs. Every map run first checks the map rule against the shipped rule on a shuffled random world. Their rows are in 030/data/ (one folder per run, a folder per job), and are embedded in this page.
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 in030/data/.