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.

  1. 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.
  2. 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).
  3. 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.
  4. 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)).
  5. 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.
0.1 ms1 ms10 ms100 ms1,000 msone frame at 60 fps (16.7 ms)Hazel, the world passed in (the rule as shipped)Hazel, the world passed in (the rule as shipped): 331 ms331 msHazel, passed as a handle (Hazel.value)Hazel, passed as a handle (Hazel.value): 235 ms235 msFumola, the world passed in as textFumola, the world passed in as text: 169 ms169 msFumola, held as a listFumola, held as a list: 27.7 ms27.7 msHazel, held in the game's map (Common.squares)Hazel, held in the game's map (Common.squares): 1.46 ms1.46 msFumola, held in a HashMapFumola, held in a HashMap: 0.587 ms0.587 ms
Figure 1. One World move (a way out walked through, mid-map, going east) on a 64x64 world, ms, log scale, against one 60 fps frame. Hazel on 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 piecewhere it is
Hazel code in the game, one module per room plus 📚 CommonThe 🧩 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 gameThe 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 defaultToday a page-wide ?logic= switch, Hazel and Fumola on separate PRs. ^ticket(566).planned
The three equivalent, held by tests as the code changesNode 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 JSThe 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 fpsLevers, 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 enoughYes: about a millisecond a call, flat with its HashMap (0.587 ms at 64x64).built

What was built#

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.

every box
The program's start (Hazel): the sum types

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
📚 Common
# 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
🏰 the trap room, over the castle's dungeons
# 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
🌳 the tree houses' trap doors, and the round room's at the top
# 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
🎚️ placed levers and trap doors, in any square or room
# 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
🗺️ the world's ways: which square a way out leads to
# 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
🏰 the castle's ways: where a pit drops you, and a dungeon's ladder leads
# 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

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 paperits game conceptin Messed Up Dress Up's rules
syntaxrecognized player intents: direction ::= north | south | east | west, intent ::= move⟨direction⟩ | collectthe 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 semanticsgame 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 systemcontextual interface: only the intents that make sense in this statethe 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 responseproved, in a PL, by caseschecked, 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 programsa log of intents and responses, as structured termsa 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 programsplayers 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:

programnpassespasses, fast pathtime, fast path / without
count1,00014,0099,0070.92
fib1646,29433,5250.90
h_walk3,00039,01227,0100.98
h_ref3,0003,00870.03
sumbuild3,00093,02272,0190.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.

0.1 ms1 ms10 ms100 ms1,000 ms60 fps framepassed in, report 27's build (r25-best)passed in, report 27's build (r25-best): 241 ms241 msheld in the map, r25-best (no 547: re-walked)held in the map, r25-best (no 547: re-walked): 335 ms335 mspassed in, with 547, 548, 551 (r25-553)passed in, with 547, 548, 551 (r25-553): 236 ms236 mspassed as a handle (551)passed as a handle (551): 172 ms172 msheld in the map, with 547, 548, 551held in the map, with 547, 548, 551: 0.97 ms0.97 ms
Figure 2. Hazel's World move at 64x64, ms, log scale, as the fixes stack: ^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, passedHazel, mapFumola, passedFumola, HashMapHazel, map's set-upFumola, set-up
3x3 (9)1.250.821.230.6384.1839.1
4x4 (16)1.781.061.420.6017.4825.7
6x6 (36)3.91.182.230.60217.226.3
8x8 (64)5.691.153.360.60329.128.5
12x12 (144)11.61.546.320.58867.233.9
16x16 (256)19.51.2310.50.59312041.5
24x24 (576)43.91.6522.20.58427765.7
32x32 (1,024)79.51.3439.10.59551894.1
48x48 (2,304)1822.0389.60.5871,180189
64x64 (4,096)3311.461690.5872,136326

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#

What next#

  1. 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.
  2. Equivalence in CI (^ticket(567)): for every handler and event, the shipped Hazel and Fumola texts answer as the JS does, on generated worlds.
  3. A 🔬 tool for each script's overhead over JS (^ticket(568)), per handler and per engine, live in the game.
  4. Speed standards per setting (^ticket(569)): speed runs for JS, Hazel and Fumola that hold the rooms each controls to a minimum frame rate.
  5. 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.
  6. Hold the map in the game: call with_squares once a world, not the list rule every way; then the placed levers' rule with its things in maps (by place, and by id with string_compare).
  7. 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#