diff --git a/content/about-treehouse/emoji.tree b/content/about-treehouse/emoji.tree index ca4f067..48b0658 100644 --- a/content/about-treehouse/emoji.tree +++ b/content/about-treehouse/emoji.tree @@ -67,6 +67,13 @@ % id = "01H8W7VEVPSHDWDH58HFKBMGD6" - but I don't wanna replace it because it's just too good +- ### random places + + % id = "emoji/ahyes" + - :ahyes: - ah, yes + + - smuggest expression for the smuggest of moments, and some tea with it. + % id = "01HA4HJKQ7FKV8JJ70Q2CY9R86" - ### [Noto Color Emoji](https://github.com/googlefonts/noto-emoji/) diff --git a/content/programming/blog/tairu.tree b/content/programming/blog/tairu.tree index 731cc33..98a0eb6 100644 --- a/content/programming/blog/tairu.tree +++ b/content/programming/blog/tairu.tree @@ -1,11 +1,6 @@ %% title = "tairu - an interactive exploration of 2D autotiling techniques" scripts = [ "components/literate-programming.js", - "tairu/cardinal-directions.js", - "tairu/framework.js", - "tairu/tairu.js", - "tairu/tilemap-registry.js", - "tairu/tilemap.js", "vendor/codejar.js", ] styles = ["tairu.css"] @@ -20,8 +15,9 @@ styles = ["tairu.css"] - TODO: short videos demoing this here % id = "01HPD4XQPWJBTJ4DWAQE3J87C9" -- once upon a time I stumbled upon a technique called...\ -**bitwise autotiling** +- once upon a time I stumbled upon a technique called... + +- ### bitwise autotiling % id = "01HPD4XQPW6VK3FDW5QRCE6HSS" + I learned about it way back when I was just a kid building 2D Minecraft clones using [Construct 2](https://www.construct.net/en/construct-2/manuals/construct-2), and I wanted my terrain to look nice as it does in Terraria @@ -29,37 +25,194 @@ styles = ["tairu.css"] % id = "01HPD4XQPWJ1CE9ZVRW98X7HE6" - Construct 2 was one of my first programming experiences and the first game engine I truly actually liked :smile: - % id = "01HPJ8GHDET8ZGNN0AH3FWA8HX" - - let's begin with a tilemap. say we have the following grid of tiles: (the examples are interactive, try editing it!) + - so to help us learn, I made a little tile editor so that we can experiment with rendering tiles! have a look: - - Your browser does not support <canvas>. - + ```javascript tairu + import { Tilemap } from "tairu/tilemap.js"; + import { TileEditor } from "tairu/editor.js"; - % id = "01HPJ8GHDEC0Z334M04MTNADV9" - - for each tile we can assign a bitset of cardinal directions, based on which tiles it should connect to - like so: + export const tilemapSquare = Tilemap.parse(" x", [ + " ", + " xxx ", + " xxx ", + " xxx ", + " ", + ]); - - Your browser does not support <canvas>. - + new TileEditor({ + tilemap: tilemapSquare, + tileSize: 40, + }); + ``` + + ```output tairu + ``` + + - `Tilemap` is a class wrapping a flat [`Uint8Array`] with a `width` and a `height`, so that we can index it using (x, y) coordinates. + + ```javascript tairu + console.log(tilemapSquare.at(0, 0)); + console.log(tilemapSquare.at(3, 1)); + ``` + ```output tairu + ``` + + [`Uint8Array`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array + + - `at` has a `setAt` counterpart which sets tiles instead of getting them. + + - `TileEditor` provides a graphical editor for a `Tilemap` based on a ``. + + - this editor is _Certified Battery Efficient™_, so it won't redraw unless it needs to!\ + we'll need to keep this in mind for later when we try to draw images, which may not be loaded during the initial draw. + + - to kick this off, let's set off a goal. I would like the tiles in our little renderer to connect together, like this: + + ![red rectangle with a black outline, made out of 3x3 tiles][pic:01HPYW5SNTY0Z0ENDE5K3XWMTH] + + - let's break this down into smaller steps. drawing a border around the rectangle will involve: + + - determining *on which tiles* to draw it, + + - determining *where in these tiles* to draw it, + + - and actually drawing it! + + - so let's zoom in a bit and look at the tiles one by one. in particular, let's focus on *these* two tiles: + + ![the same red rectangle, now with a focus on the northern tile at its center][pic:01HPYWPJB1P0GK53BSJFJFRAGR] + + - notice how the two highlighted tiles are *different.* therefore, we can infer we should probably connect together any tiles that are *the same*. + + - knowing that, we can extract the logic to a function: + + ```javascript tairu + export function shouldConnect(a, b) { + return a == b; + } + ``` + + + now, also note that the border around this particular tile is only drawn on its *northern* edge - + therefore we can infer that borders should only be drawn on edges for whom `shouldConnect(thisTile, adjacentTile)` is **`false`** (not `true`!). + a tile generally has four edges - east, south, west, north - so we need to perform this check for all of them, and draw our border accordingly. + + - you might be wondering why I'm using this particular order for cardinal directions - why not [north, south, east, west]? or [north, east, south, west]? + + - the reason comes from math - `[cos(0) sin(0)]` is a vector pointing rightwards, not upwards! + and I chose clockwise order, because that's how the vector rotates as we increase the angle, in a coordinate space where +Y points downward - such as the `` coordinate space. + + - this choice yields some nice orderliness in the code that handles fetching tiles for connections - first you check `+X`, then `+Y`, then `-X`, and then `-Y` - + which my pedantic mind really appreciates :ahyes:\ + as `X` is first alphabetically, so checking `Y` first would feel wrong. + + - to do that, I'm gonna override the tile editor's `drawTilemap` function - as this is where the actual tilemap rendering happens! + + ```javascript tairu + import { TileEditor } from "tairu/editor.js"; + + export class TileEditorWithBorders extends TileEditor { + constructor({ borderWidth, ...options }) { + super(options); + + this.borderWidth = borderWidth; + this.colorScheme.borderColor = "#000000"; + } + + drawTilemap() { + // Let the base class render out the infill, we'll just handle the borders. + super.drawTilemap(); + + this.ctx.fillStyle = this.colorScheme.borderColor; + + for (let y = 0; y < this.tilemap.height; ++y) { + for (let x = 0; x < this.tilemap.width; ++x) { + let tile = this.tilemap.at(x, y); + // We only want to draw non-empty tiles, so skip tile 0. + if (tile == 0) { + continue; + } + + // Check which of this tile's neighbors should *not* connect to it. + let disjointWithEast = !shouldConnect(tile, this.tilemap.at(x + 1, y)); + let disjointWithSouth = !shouldConnect(tile, this.tilemap.at(x, y + 1)); + let disjointWithWest = !shouldConnect(tile, this.tilemap.at(x - 1, y)); + let disjointWithNorth = !shouldConnect(tile, this.tilemap.at(x, y - 1)); + + let { borderWidth, tileSize } = this; + let tx = x * tileSize; + let ty = y * tileSize; + + // For each disjoint neighbor, we want to draw a border between us and them. + if (disjointWithEast) { + this.ctx.fillRect(tx + tileSize - borderWidth, ty, borderWidth, tileSize); + } + if (disjointWithSouth) { + this.ctx.fillRect(tx, ty + tileSize - borderWidth, tileSize, borderWidth); + } + if (disjointWithWest) { + this.ctx.fillRect(tx, ty, borderWidth, tileSize); + } + if (disjointWithNorth) { + this.ctx.fillRect(tx, ty, tileSize, borderWidth); + } + } + } + } + } + ``` + + and here's the result: + + ```javascript tairu + new TileEditorWithBorders({ + tilemap: tilemapSquare, + tileSize: 40, + borderWidth: 4, + }); + ``` + ```output tairu + ``` + + - this looks pretty perfect - maybe sans corners, which I'll conveniently skip for now - because most games don't actually render graphics in a vectorial way like this! + instead, the more common way is to use a tileset - a big texture with a bunch of sprites to use for rendering each tile. + + - not only does this have the advantage of allowing for richer graphics, but it is also a lot easier to modify by artists, because you no longer need knowledge of graphics APIs to draw tiles. % template = true - id = "01HPJ8GHDE9QKQ4QFZK1Z1KQD4" classes.branch = "tileset-cardinal-directions-demo" - + now given a tileset, such as the one below that I drew a while ago, we can assign each tile to a set of cardinal directions. - I'll indicate where there's a connection between individual tiles with the letters **N**, **E**, **S**, **W**, standing for the cardinal directions **N**orth, **E**ast, **S**outh, and **W**est. + - for example, here's a tileset I drew for the 3rd iteration of my game [Planet Overgamma] - though tweaked a bit because I had never used it before writing this post :hueh: - + - % id = "01HPMVT9BM65YD5AXWPT4Z67H5" - - (it's frustratingly hard to center individual letters like this in CSS. please forgive me for how crooked these are!) + - previously we represented which single border to draw with a single boolean. + now we will represent which single tile to draw with *four* booleans, because each tile can connect to four different directions. - % id = "01HPMVT9BM5V4BP8K80X0C1HJZ" - - note that the state of connection for a given cardinal direction can be represented using two values: **connected**, and **not connected**. - two values make one bit, so we can pack these four connection states into four bits, and use that as an array index! + - four booleans like this can easily be packed into a single integer using some bitwise operations, hence we get ***bitwise autotiling*** - autotiling using bitwise operations! + + - now the clever part of bitwise autotiling is that we can use this packed integer *as an array index* - therefore selecting which tile to draw can be determined using just a single lookup table! neat, huh? + + - but because I'm lazy, and CPU time is valuable, instead of using an array I'll just rearrange the tileset texture a bit to be able to slice it in place using this index. + + - say we arrange our bits like this: + + ```javascript tairu + export const E = 0b0001; + export const S = 0b0010; + export const W = 0b0100; + export const N = 0b1000; + ``` % classes.branch = "tileset-cardinal-directions-demo" - id = "01HPMVT9BM4AXG2Z1D2QBH828G" - + for that to work though, we need to rearrange our tilemap somewhat such that we can index into it easily using our integer. - assuming we pack our bits as `NWSE` (bit 0 is east, each next bit we go clockwise), - therefore the final arrangement is this: + - that means we'll need to arrange our tiles like so, where the leftmost tile is at index 0 (`0b0000`) and the rightmost tile is at index 15 (`0b1111`):
@@ -111,57 +271,129 @@ styles = ["tairu.css"] ESWN
- packing that into a single tilesheet, or rather tile *strip*, we get this image: + - packing that into a single tileset, or rather this time, a *tile strip*, we get this image: ![horizontal tile strip of 16 8x8 pixel metal tiles][pic:01HPMMR6DGKYTPZ9CK0WQWKNX5] - % id = "01HPQCCV4RB65D5Q4RANJKGC0D" - - **hint:** you can actually just use the original image, but use a lookup table from these indices to (x, y) coordinates. - this makes creating the assets a lot easier! (at the expense of some CPU time, though it is totally possible to offload tilemap rendering to the GPU - in that case it barely even matters.) + - now it's time to actually implement it as code! I'll start by defining a *tile index* function as a general way of looking up tiles in a tileset. - % id = "01HPMVT9BMMEM4HT4ANZ40992P" - - in JavaScript, drawing on a `` using bitwise autotiling would look like this: - ```javascript - for (let y = 0; y < tilemap.height; ++y) { - for (let x = 0; x < tilemap.width; ++x) { - // Assume `tilemap.at` is a function which returns the type of tile - // stored at coordinates (x, y). - let tile = tilemap.at(x, y); + - I want to make the tile renderer a bit more general, so being able to attach a different tile lookup function to each tileset sounds like a great feature. - // We need to treat *some* tile as an empty (fully transparent) tile. - // In our case that'll be 0. - if (tile != 0) { - let tileset = tilesets[tile]; + - just imagine some game where glass connects to metal, but metal doesn't connect to glass - I bet that would look pretty great! - // Now it's time to represent the tile connections as bits. - // For each cardinal direction we produce a different bit value, or 0 if there is - // no connection: - let connectedWithEast = shouldConnect(tile, tilemap.at(x + 1, y)) ? 0b0001 : 0; - let connectedWithSouth = shouldConnect(tile, tilemap.at(x, y + 1)) ? 0b0010 : 0; - let connectedWithWest = shouldConnect(tile, tilemap.at(x - 1, y)) ? 0b0100 : 0; - let connectedWithNorth = shouldConnect(tile, tilemap.at(x, y - 1)) ? 0b1000 : 0; - // Then we OR them together into one integer. - let tileIndex = connectedWithNorth - | connectedWithWest - | connectedWithSouth - | connectedWithEast; + - …but anyways, here's the basic bitwise magic function: - // With that, we can draw the correct tile. - // Our strip is a single horizontal line, so we can assume - let tilesetTileSize = tileset.height; - let tilesetX = tileIndex * tilesetTileSize; - let tilesetY = 0; - ctx.drawImage( - tilesets[tile], - tilesetX, tilesetY, tilesetTileSize, tilesetTileSize, - x * tileSize, y * tileSize, tileSize, tileSize, - ); + ```javascript tairu + export function tileIndexInBitwiseTileset(tilemap, x, y) { + let tile = tilemap.at(x, y); + + let tileIndex = 0; + tileIndex |= shouldConnect(tile, tilemap.at(x + 1, y)) ? E : 0; + tileIndex |= shouldConnect(tile, tilemap.at(x, y + 1)) ? S : 0; + tileIndex |= shouldConnect(tile, tilemap.at(x - 1, y)) ? W : 0; + tileIndex |= shouldConnect(tile, tilemap.at(x, y - 1)) ? N : 0; + + return tileIndex; + } + ``` + + % template = true + - we'll define our tilesets by their texture, tile size, and a tile indexing function. so let's create an object that will hold our tileset data: + + ```javascript tairu + // You'll probably want to host the assets on your own website rather than + // hotlinking to others. It helps longevity! + const tilesetImage = new Image(); + tilesetImage.src = "{% pic 01HPMMR6DGKYTPZ9CK0WQWKNX5 %}"; + + export const heavyMetalTileset = { + image: tilesetImage, + tileSize: 8, + tileIndex: tileIndexInBitwiseTileset, + }; + ``` + + - with all that, we should now be able to write a tile renderer which can handle textures! so let's try it: + + ```javascript tairu + import { TileEditor } from "tairu/editor.js"; + + export class TilesetTileEditor extends TileEditor { + constructor({ tilesets, ...options }) { + super(options); + this.tilesets = tilesets; + + // The image may not be loaded once the editor is first drawn, so we need to request a + // redraw for each image that gets loaded in. + for (let tileset of this.tilesets) { + tileset.image.addEventListener("load", () => this.draw()); + } + } + + drawTilemap() { + // We're dealing with pixel tiles so we want our images to be pixelated, + // not interpolated. + this.ctx.imageSmoothingEnabled = false; + + for (let y = 0; y < this.tilemap.height; ++y) { + for (let x = 0; x < this.tilemap.width; ++x) { + let tile = this.tilemap.at(x, y); + if (tile == 0) { + continue; + } + + // Subtract one from the tile because tile 0 is always empty. + // Having to specify a null entry at array index 0 would be pretty annoying. + let tileset = this.tilesets[tile - 1]; + if (tileset != null) { + let { tileSize } = this; + let tileIndex = tileset.tileIndex(this.tilemap, x, y); + this.ctx.drawImage( + tileset.image, + tileIndex * tileset.tileSize, 0, tileset.tileSize, tileset.tileSize, + x * tileSize, y * tileSize, tileSize, tileSize, + ); + } + } } } } ``` - TODO this should be literate code + - drum roll please... + + ```javascript tairu + new TilesetTileEditor({ + tilemap: tilemapSquare, + tileSize: 40, + tilesets: [heavyMetalTileset], + }); + ``` + ```output tairu + ``` + + - it works! buuuut if you play around with it you'll quickly start noticing some problems: + + ```javascript tairu + import { Tilemap } from "tairu/tilemap.js"; + + export const tilemapEdgeCase = Tilemap.parse(" x", [ + " ", + " xxx ", + " x x ", + " xxx ", + " ", + ]); + new TilesetTileEditor({ + tilemap: tilemapEdgeCase, + tileSize: 40, + tilesets: [heavyMetalTileset], + }); + ``` + ```output tairu + ``` + + - where did our nice seamless connections go!? % template = true id = "01HPMVT9BM9CS9375MX4H9WKW8" diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..5eb03be --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "baseUrl": "./static/js", + "paths": { + "treehouse/*": [ + "./*" + ], + "tairu/*": [ + "./components/tairu/*" + ] + } + }, +} diff --git a/static/css/main.css b/static/css/main.css index bd190ba..909b986 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -255,14 +255,12 @@ th-literate-program { .tree summary:hover { & pre, - & th-literate-program { + & th-literate-program:not([data-mode="output"]) { background-color: var(--shaded-against-background-twice); } } } - - pre>code, th-literate-program>code { padding: 0; @@ -276,7 +274,8 @@ th-literate-program { /* And don't let code examples fly off and overflow the window */ -pre { +pre, +th-literate-program { min-width: 0; width: auto; overflow: auto; @@ -554,30 +553,6 @@ th-literate-program[data-mode="input"] { } th-literate-program[data-mode="output"] { - position: relative; - - & code { - display: block; - } - - & code.error { - color: var(--error-color); - } - - &::after { - content: 'Output'; - - padding: 8px; - - position: absolute; - right: 0; - top: 0; - - opacity: 50%; - } -} - -th-literate-program[data-mode="graphics"] { padding: 0; background: none; border: none; @@ -593,6 +568,10 @@ th-literate-program[data-mode="graphics"] { display: none; } + & pre>code { + display: block; + } + & pre.error { color: var(--error-color); position: relative; @@ -614,6 +593,27 @@ th-literate-program[data-mode="graphics"] { opacity: 50%; } } + + & pre.console { + position: relative; + + &:empty { + display: none; + } + + &::after { + content: 'Console'; + + padding: 8px; + + position: absolute; + right: 0; + top: 0; + + color: var(--text-color); + opacity: 50%; + } + } } /* Syntax highlighting */ diff --git a/static/css/tairu.css b/static/css/tairu.css index 18e562d..ebb826d 100644 --- a/static/css/tairu.css +++ b/static/css/tairu.css @@ -1,28 +1,4 @@ .tileset-cardinal-directions-demo th-bc { - & ul { - display: flex; - flex-direction: row; - } - - & ul.tileset-demo { - margin-top: 16px; - } - - & ul.tileset-demo::after { - display: none !important; - } - - & li.full-image { - flex-shrink: 0; - } - - & li.tileset-pieces { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - } - & .horizontal-tile-strip { display: flex; flex-direction: row; diff --git a/static/css/tree.css b/static/css/tree.css index 070f93f..4d3f3ef 100644 --- a/static/css/tree.css +++ b/static/css/tree.css @@ -218,8 +218,8 @@ th-bc { flex-grow: 1; /* Bit of a hack to make
s in  have scrollbars proper. */
-    &:has(pre) {
-        overflow: auto;
+    &:has(pre, th-literate-program) {
+        overflow: hidden;
     }
 }
 
diff --git a/static/emoji/ahyes.png b/static/emoji/ahyes.png
new file mode 100644
index 0000000..8da75f8
Binary files /dev/null and b/static/emoji/ahyes.png differ
diff --git a/static/js/components/literate-programming.js b/static/js/components/literate-programming.js
index e59756a..3fcc1e9 100644
--- a/static/js/components/literate-programming.js
+++ b/static/js/components/literate-programming.js
@@ -12,10 +12,8 @@ function getLiterateProgram(name) {
             outputCount: 0,
 
             nextOutputIndex() {
-                let index = this.outputCount;
-                ++this.outputCount;
-                return index;
-            }
+                return this.outputCount++;
+            },
         });
     }
     return literatePrograms.get(name);
@@ -28,7 +26,7 @@ function getLiterateProgramWorkerCommands(name) {
         if (frame.mode == "input") {
             commands.push({ kind: "module", source: frame.textContent });
         } else if (frame.mode == "output") {
-            commands.push({ kind: "output", expected: frame.textContent });
+            commands.push({ kind: "output" });
         }
     }
     return commands;
@@ -51,7 +49,7 @@ class InputMode {
             { regex: /"(\\"|[^"])*"/, as: "string" },
             { regex: /`(\\`|[^"])*`/, as: "string" },
             // TODO: RegExp literals?
-            { regex: /[+=/*^%<>!~|&\.-]+/, as: "operator" },
+            { regex: /[+=/*^%<>!~|&\.?:-]+/, as: "operator" },
             { regex: /[,;]/, as: "punct" },
         ],
         keywords: new Map([
@@ -132,54 +130,84 @@ class InputMode {
     }
 }
 
+function messageOutputArrayToString(output) {
+    return output
+        .map(x => {
+            if (typeof x === "object") return JSON.stringify(x);
+            else return x + "";
+        })
+        .join(" ");
+}
+
 class OutputMode {
     constructor(frame) {
-        this.clearResultsOnNextOutput = false;
-
         this.frame = frame;
 
-        this.frame.program.onChanged.push(_ => this.evaluate());
         this.outputIndex = this.frame.program.nextOutputIndex();
 
-        this.evaluate();
-    }
+        this.console = document.createElement("pre");
+        this.console.classList.add("console");
+        this.frame.appendChild(this.console);
+        this.clearConsoleOnNextOutput = false;
 
-    evaluate() {
-        // This is a small bit of debouncing. If we cleared the output right away, the page would
-        // jitter around irritatingly.
-        this.clearResultsOnNextOutput = true;
+        this.error = document.createElement("pre");
+        this.error.classList.add("error");
+        this.frame.appendChild(this.error);
 
-        if (this.worker != null) {
-            this.worker.terminate();
-        }
-        this.worker = new Worker(import.meta.resolve("./literate-programming/worker.js"), {
-            type: "module",
-            name: `evaluate LiterateOutput ${this.frame.programName}`
-        });
+        this.iframe = document.createElement("iframe");
+        this.iframe.classList.add("hidden");
+        this.iframe.src = `${TREEHOUSE_SITE}/sandbox`;
+        this.frame.appendChild(this.iframe);
 
-        this.worker.addEventListener("message", event => {
+        this.iframe.contentWindow.treehouseSandboxInternals = { outputIndex: this.outputIndex };
+
+        this.iframe.contentWindow.addEventListener("message", event => {
             let message = event.data;
-            if (message.kind == "evalComplete") {
-                this.worker.terminate();
+            if (message.kind == "ready") {
+                this.evaluate();
+            } else if (message.kind == "resize" && message.outputIndex == this.outputIndex) {
+                this.resize();
             } else if (message.kind == "output" && message.outputIndex == this.outputIndex) {
-                this.addOutput(message.output);
+                if (message.output.kind == "error") {
+                    this.error.textContent = messageOutputArrayToString(message.output.message);
+                    this.iframe.classList.add("hidden");
+                } else {
+                    this.addOutput(message.output);
+                }
+            } else if (message.kind == "evalComplete") {
+                this.error.textContent = "";
+                this.flushConsoleClear();
             }
         });
 
-        this.worker.postMessage({
+        this.frame.program.onChanged.push(_ => this.evaluate());
+    }
+
+    evaluate() {
+        this.requestConsoleClear();
+        this.iframe.contentWindow.postMessage({
             action: "eval",
             input: getLiterateProgramWorkerCommands(this.frame.programName),
         });
     }
 
-    addOutput(output) {
-        if (this.clearResultsOnNextOutput) {
-            this.clearResultsOnNextOutput = false;
-            this.clearResults();
-        }
+    clearConsole() {
+        this.console.replaceChildren();
+    }
 
-        // Don't show anything if the function didn't return a value.
-        if (output.kind == "result" && output.message[0] === undefined) return;
+    requestConsoleClear() {
+        this.clearConsoleOnNextOutput = true;
+    }
+
+    flushConsoleClear() {
+        if (this.clearConsoleOnNextOutput) {
+            this.clearConsole();
+            this.clearConsoleOnNextOutput = false;
+        }
+    }
+
+    addOutput(output) {
+        this.flushConsoleClear();
 
         let line = document.createElement("code");
 
@@ -194,65 +222,22 @@ class OutputMode {
             })
             .join(" ");
 
-        this.frame.appendChild(line);
+        this.console.appendChild(line);
     }
 
-    clearResults() {
-        this.frame.replaceChildren();
-    }
-
-    static messageOutputArrayToString(output) {
-        return output
-            .map(x => {
-                if (typeof x === "object") return JSON.stringify(x);
-                else return x + "";
-            })
-            .join(" ");
-    }
-}
-
-class GraphicsMode {
-    constructor(frame) {
-        this.frame = frame;
-
-        this.error = document.createElement("pre");
-        this.error.classList.add("error");
-        this.frame.appendChild(this.error);
-
-        this.iframe = document.createElement("iframe");
-        this.iframe.classList.add("hidden");
-        this.iframe.src = import.meta.resolve("../../html/sandbox.html");
-        this.frame.appendChild(this.iframe);
-
-        this.iframe.contentWindow.addEventListener("message", event => {
-            let message = event.data;
-            if (message.kind == "ready") {
-                this.evaluate();
-            }
-            else if (message.kind == "resize") {
-                this.resize(message);
-            } else if (message.kind == "output" && message.output.kind == "error") {
-                this.error.textContent = OutputMode.messageOutputArrayToString(message.output.message);
-                this.iframe.classList.add("hidden");
-            } else if (message.kind == "evalComplete") {
-                this.error.textContent = "";
-            }
-        });
-
-        this.frame.program.onChanged.push(_ => this.evaluate());
-    }
-
-    evaluate() {
-        this.iframe.contentWindow.postMessage({
-            action: "eval",
-            input: getLiterateProgramWorkerCommands(this.frame.programName),
-        });
-    }
-
-    resize(message) {
-        this.iframe.width = message.width;
-        this.iframe.height = message.height;
+    resize() {
+        // iframe cannot be `display: none` to get its scrollWidth/scrollHeight.
         this.iframe.classList.remove("hidden");
+
+        let width = this.iframe.contentDocument.body.scrollWidth;
+        let height = this.iframe.contentDocument.body.scrollHeight;
+
+        if (width == 0 || height == 0) {
+            this.iframe.classList.add("hidden");
+        } else {
+            this.iframe.width = width;
+            this.iframe.height = height;
+        }
     }
 }
 
@@ -266,8 +251,6 @@ class LiterateProgram extends HTMLElement {
             this.modeImpl = new InputMode(this);
         } else if (this.mode == "output") {
             this.modeImpl = new OutputMode(this);
-        } else if (this.mode == "graphics") {
-            this.modeImpl = new GraphicsMode(this);
         }
     }
 
diff --git a/static/js/components/literate-programming/eval.js b/static/js/components/literate-programming/eval.js
index f7b86de..19db75c 100644
--- a/static/js/components/literate-programming/eval.js
+++ b/static/js/components/literate-programming/eval.js
@@ -1,8 +1,21 @@
 let outputIndex = 0;
 
-export function getOutputIndex() {
-    return outputIndex;
-}
+export const jsConsole = console;
+
+// Overwrite globalThis.console with domConsole to redirect output to the DOM console.
+// To always output to the JavaScript console regardless, use jsConsole.
+export const domConsole = {
+    log(...message) {
+        postMessage({
+            kind: "output",
+            output: {
+                kind: "console.log",
+                message: [...message],
+            },
+            outputIndex,
+        });
+    }
+};
 
 async function withTemporaryGlobalScope(callback) {
     let state = {
@@ -13,6 +26,7 @@ async function withTemporaryGlobalScope(callback) {
         }
     };
     await callback(state);
+    jsConsole.trace(state.oldValues, "bringing back old state");
     for (let key in state.oldValues) {
         globalThis[key] = state.oldValues[key];
     }
@@ -20,15 +34,11 @@ async function withTemporaryGlobalScope(callback) {
 
 let evaluationComplete = null;
 
-export async function evaluate(commands, { start, success, error }) {
+export async function evaluate(commands, { error, newOutput }) {
     if (evaluationComplete != null) {
         await evaluationComplete;
     }
 
-    if (start != null) {
-        start();
-    }
-
     let signalEvaluationComplete;
     evaluationComplete = new Promise((resolve, _reject) => {
         signalEvaluationComplete = resolve;
@@ -36,21 +46,19 @@ export async function evaluate(commands, { start, success, error }) {
 
     outputIndex = 0;
     try {
-        await withTemporaryGlobalScope(async scope => {
-            for (let command of commands) {
-                if (command.kind == "module") {
-                    let blobUrl = URL.createObjectURL(new Blob([command.source], { type: "text/javascript" }));
-                    let module = await import(blobUrl);
-                    for (let exportedKey in module) {
-                        scope.set(exportedKey, module[exportedKey]);
-                    }
-                } else if (command.kind == "output") {
-                    ++outputIndex;
+        for (let command of commands) {
+            if (command.kind == "module") {
+                let blobUrl = URL.createObjectURL(new Blob([command.source], { type: "text/javascript" }));
+                let module = await import(blobUrl);
+                for (let exportedKey in module) {
+                    globalThis[exportedKey] = module[exportedKey];
                 }
+            } else if (command.kind == "output") {
+                if (newOutput != null) {
+                    newOutput(outputIndex);
+                }
+                ++outputIndex;
             }
-        });
-        if (success != null) {
-            success();
         }
         postMessage({
             kind: "evalComplete",
diff --git a/static/js/components/literate-programming/worker.js b/static/js/components/literate-programming/worker.js
deleted file mode 100644
index 381126c..0000000
--- a/static/js/components/literate-programming/worker.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { evaluate, getOutputIndex } from "./eval.js";
-
-let debugLog = console.log;
-
-globalThis.console = {
-    log(...message) {
-        postMessage({
-            kind: "output",
-            output: {
-                kind: "log",
-                message: [...message],
-            },
-            outputIndex: getOutputIndex(),
-        });
-    }
-};
-
-addEventListener("message", async event => {
-    let message = event.data;
-    if (message.action == "eval") {
-        evaluate(message.input, {});
-    }
-});
diff --git a/static/js/components/tairu/cardinal-directions.js b/static/js/components/tairu/cardinal-directions.js
new file mode 100644
index 0000000..e69de29
diff --git a/static/js/components/tairu/editor.js b/static/js/components/tairu/editor.js
new file mode 100644
index 0000000..d357e57
--- /dev/null
+++ b/static/js/components/tairu/editor.js
@@ -0,0 +1,134 @@
+import { Sketch } from "treehouse/sandbox.js";
+
+export class TileEditor extends Sketch {
+    constructor({ tilemap, tileSize }) {
+        super(tilemap.width * tileSize, tilemap.height * tileSize);
+
+        this.colorScheme = {
+            background: "#F7F7F7",
+            grid: "#00000011",
+            tileCursor: "#222222",
+            tiles: [
+                "transparent", // never actually drawn to the screen with the default renderer!
+                "#eb134a",
+            ],
+        };
+
+        this.tilemap = tilemap;
+        this.tileSize = tileSize;
+
+        this.hasFocus = false;
+        this.paintingTile = null;
+        this.tileCursor = { x: 0, y: 0 };
+
+        this.canvas.addEventListener("mousemove", event => this.mouseMoved(event));
+        this.canvas.addEventListener("mousedown", event => this.mousePressed(event));
+        this.canvas.addEventListener("mouseup", event => this.mouseReleased(event));
+
+        this.canvas.addEventListener("mouseenter", _ => this.mouseEnter());
+        this.canvas.addEventListener("mouseleave", _ => this.mouseLeave());
+
+        this.canvas.addEventListener("contextmenu", event => event.preventDefault());
+
+        // Only draw first frame after the constructor already runs.
+        // That way we can modify the color scheme however much we want without causing additional
+        // redraws.
+        requestAnimationFrame(() => this.draw());
+    }
+
+    draw() {
+        this.drawBackground();
+        this.drawTilemap();
+        this.drawGrid();
+        if (this.hasFocus) {
+            this.drawTileCursor();
+        }
+    }
+
+    drawBackground() {
+        this.ctx.fillStyle = this.colorScheme.background;
+        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+    }
+
+    drawTilemap() {
+        for (let y = 0; y < this.tilemap.height; ++y) {
+            for (let x = 0; x < this.tilemap.width; ++x) {
+                let tile = this.tilemap.at(x, y);
+                if (tile != 0) {
+                    this.ctx.fillStyle = this.colorScheme.tiles[tile];
+                    this.ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize);
+                }
+            }
+        }
+    }
+
+    drawGrid() {
+        this.ctx.beginPath();
+        for (let x = 0; x < this.tilemap.width; ++x) {
+            this.ctx.moveTo(x * this.tileSize, 0);
+            this.ctx.lineTo(x * this.tileSize, this.canvas.height);
+        }
+        for (let y = 0; y < this.tilemap.width; ++y) {
+            this.ctx.moveTo(0, y * this.tileSize);
+            this.ctx.lineTo(this.canvas.width, y * this.tileSize);
+        }
+        this.ctx.strokeStyle = this.colorScheme.grid;
+        this.ctx.lineWidth = 1;
+        this.ctx.stroke();
+    }
+
+    drawTileCursor() {
+        this.ctx.strokeStyle = this.colorScheme.tileCursor;
+        this.ctx.lineWidth = 5;
+        this.ctx.strokeRect(this.tileCursor.x * this.tileSize, this.tileCursor.y * this.tileSize, this.tileSize, this.tileSize);
+    }
+
+
+    mouseMoved(event) {
+        this.tileCursor.x = Math.floor(event.offsetX / this.tileSize);
+        this.tileCursor.y = Math.floor(event.offsetY / this.tileSize);
+        this.paintTileUnderCursor();
+
+        this.draw();
+    }
+
+    mousePressed(event) {
+        event.preventDefault();
+
+        if (event.button == 0) {
+            this.paintingTile = 1;
+        } else if (event.button == 2) {
+            this.paintingTile = 0;
+        }
+
+        this.paintTileUnderCursor();
+
+        this.draw();
+    }
+
+    mouseReleased(_event) {
+        this.stopPainting();
+        this.draw();
+    }
+
+    mouseEnter() {
+        this.hasFocus = true;
+        this.draw();
+    }
+
+    mouseLeave() {
+        this.hasFocus = false;
+        this.stopPainting();
+        this.draw();
+    }
+
+    paintTileUnderCursor() {
+        if (this.paintingTile != null) {
+            this.tilemap.setAt(this.tileCursor.x, this.tileCursor.y, this.paintingTile);
+        }
+    }
+
+    stopPainting() {
+        this.paintingTile = null;
+    }
+}
diff --git a/static/js/tairu/framework.js b/static/js/components/tairu/framework.js
similarity index 100%
rename from static/js/tairu/framework.js
rename to static/js/components/tairu/framework.js
diff --git a/static/js/tairu/cardinal-directions.js b/static/js/components/tairu/proto.js
similarity index 61%
rename from static/js/tairu/cardinal-directions.js
rename to static/js/components/tairu/proto.js
index 7a72d3d..d9eda1a 100644
--- a/static/js/tairu/cardinal-directions.js
+++ b/static/js/components/tairu/proto.js
@@ -1,9 +1,45 @@
-import { defineFrame, Frame } from './framework.js';
-import { TileEditor, canConnect, shouldConnect } from './tairu.js';
+import { TileEditor } from 'tairu/editor.js';
 
-class CardinalDirectionsEditor extends TileEditor {
-    constructor() {
-        super();
+export function alignTextInRectangle(ctx, text, x, y, width, height, hAlign, vAlign) {
+    let measurements = ctx.measureText(text);
+
+    let leftX;
+    switch (hAlign) {
+        case "left":
+            leftX = x;
+            break;
+        case "center":
+            leftX = x + width / 2 - measurements.width / 2;
+            break;
+        case "right":
+            leftX = x + width - measurements.width;
+            break;
+    }
+
+    let textHeight = measurements.fontBoundingBoxAscent;
+    let baselineY;
+    switch (vAlign) {
+        case "top":
+            baselineY = y + textHeight;
+            break;
+        case "center":
+            baselineY = y + height / 2 + textHeight / 2;
+            break;
+        case "bottom":
+            baselineY = y + height;
+            break;
+    }
+
+    return { leftX, baselineY };
+}
+
+export function shouldConnect(a, b) {
+    return a == b;
+}
+
+export class TileEditorWithCardinalDirections extends TileEditor {
+    constructor(options) {
+        super(options);
         this.colorScheme.tiles[1] = "#f96565";
     }
 
@@ -38,4 +74,3 @@ class CardinalDirectionsEditor extends TileEditor {
         }
     }
 }
-defineFrame("tairu-editor-cardinal-directions", CardinalDirectionsEditor);
diff --git a/static/js/tairu/tilemap-registry.js b/static/js/components/tairu/tilemap-registry.js
similarity index 100%
rename from static/js/tairu/tilemap-registry.js
rename to static/js/components/tairu/tilemap-registry.js
diff --git a/static/js/tairu/tilemap.js b/static/js/components/tairu/tilemap.js
similarity index 62%
rename from static/js/tairu/tilemap.js
rename to static/js/components/tairu/tilemap.js
index a519ea5..2556496 100644
--- a/static/js/tairu/tilemap.js
+++ b/static/js/components/tairu/tilemap.js
@@ -27,4 +27,16 @@ export class Tilemap {
             this.tiles[this.tileIndex(x, y)] = tile;
         }
     }
+
+    static parse(alphabet, lineArray) {
+        let tilemap = new Tilemap(lineArray[0].length, lineArray.length);
+        for (let y in lineArray) {
+            let line = lineArray[y];
+            for (let x = 0; x < line.length; ++x) {
+                let char = line.charAt(x);
+                tilemap.setAt(x, y, alphabet.indexOf(char));
+            }
+        }
+        return tilemap;
+    }
 }
diff --git a/static/js/sandbox.js b/static/js/sandbox.js
index e61520c..bf2520a 100644
--- a/static/js/sandbox.js
+++ b/static/js/sandbox.js
@@ -1,5 +1,9 @@
 export const internals = {
     body: document.createElement("body"),
+
+    resetBody() {
+        this.body.replaceChildren();
+    }
 };
 
 export function body() {
@@ -19,4 +23,13 @@ export class Sketch {
 
         addElement(this.canvas);
     }
+
+    animate(draw) {
+        let animationCallback;
+        animationCallback = () => {
+            draw();
+            requestAnimationFrame(animationCallback);
+        };
+        animationCallback();
+    }
 }
diff --git a/static/js/tairu/tairu.js b/static/js/tairu/tairu.js
deleted file mode 100644
index 6cd35a2..0000000
--- a/static/js/tairu/tairu.js
+++ /dev/null
@@ -1,251 +0,0 @@
-import { Frame, defineFrame } from "./framework.js";
-import tilemapRegistry from "./tilemap-registry.js";
-
-export function canConnect(tile) {
-    return tile == 1;
-}
-
-export function shouldConnect(a, b) {
-    return a == b;
-}
-
-const dirs47 = {
-    E: 0b0000_0001,
-    SE: 0b0000_0010,
-    S: 0b0000_0100,
-    SW: 0b0000_1000,
-    W: 0b0001_0000,
-    NW: 0b0010_0000,
-    N: 0b0100_0000,
-    NE: 0b1000_0000,
-};
-
-function isSet(integer, bit) {
-    return (integer & bit) == bit;
-}
-
-function removeRedundancies(t) {
-    if (isSet(t, dirs47.SE) && (!isSet(t, dirs47.S) || !isSet(t, dirs47.E))) {
-        t &= ~dirs47.SE;
-    }
-    if (isSet(t, dirs47.SW) && (!isSet(t, dirs47.S) || !isSet(t, dirs47.W))) {
-        t &= ~dirs47.SW;
-    }
-    if (isSet(t, dirs47.NW) && (!isSet(t, dirs47.N) || !isSet(t, dirs47.W))) {
-        t &= ~dirs47.NW;
-    }
-    if (isSet(t, dirs47.NE) && (!isSet(t, dirs47.N) || !isSet(t, dirs47.E))) {
-        t &= ~dirs47.NE;
-    }
-    return t;
-}
-
-function ordinalDirections() {
-    let unique = new Set();
-    for (let i = 0; i <= 0b1111_1111; ++i) {
-        unique.add(removeRedundancies(i));
-    }
-    return Array.from(unique).sort((a, b) => a - b);
-}
-
-let xToConnectionBitSet = ordinalDirections();
-let connectionBitSetToX = new Uint8Array(256);
-for (let i = 0; i < xToConnectionBitSet.length; ++i) {
-    connectionBitSetToX[xToConnectionBitSet[i]] = i;
-}
-console.log(connectionBitSetToX);
-
-export class TileEditor extends Frame {
-    constructor() {
-        super();
-        this.tileCursor = { x: 0, y: 0 };
-
-        this.colorScheme = {
-            background: "#F7F7F7",
-            grid: "#00000011",
-            tileCursor: "#222222",
-            tiles: [
-                "transparent",
-                "#eb134a",
-            ],
-        };
-
-        this.tileColorPalette = [
-            "transparent",
-            "#eb134a",
-        ];
-    }
-
-    connectedCallback() {
-        super.connectedCallback();
-
-        this.tileSize = parseInt(this.getAttribute("data-tile-size"));
-
-        let tilemapId = this.getAttribute("data-tilemap-id");
-        if (tilemapId != null) {
-            this.tilemap = tilemapRegistry[this.getAttribute("data-tilemap-id")];
-        } else {
-            throw new ReferenceError(`tilemap '${tilemapId}' does not exist`);
-        }
-
-        // 0st element is explicitly null because it represents the empty tile.
-        this.tilesets = [null];
-        this.tilesets47 = [null];
-
-        let attachedImages = this.getElementsByTagName("img");
-        for (let image of attachedImages) {
-            if (image.hasAttribute("data-tairu-tileset")) {
-                let tilesetIndex = parseInt(image.getAttribute("data-tairu-tileset"));
-                this.tilesets[tilesetIndex] = image;
-            } else if (image.hasAttribute("data-tairu-tileset-47")) {
-                let tilesetIndex = parseInt(image.getAttribute("data-tairu-tileset-47"));
-                this.tilesets47[tilesetIndex] = image;
-            }
-        }
-
-        this.width = this.tilemap.width * this.tileSize;
-        this.height = this.tilemap.height * this.tileSize;
-
-        this.hasFocus = false;
-        this.paintingTile = null;
-
-        this.addEventListener("mousemove", event => this.mouseMoved(event));
-        this.addEventListener("mousedown", event => this.mousePressed(event));
-        this.addEventListener("mouseup", event => this.mouseReleased(event));
-
-        this.addEventListener("mouseenter", _ => this.hasFocus = true);
-        this.addEventListener("mouseleave", _ => this.hasFocus = false);
-
-        this.addEventListener("contextmenu", event => event.preventDefault());
-
-        // TODO: This should also work on mobile.
-    }
-
-    draw() {
-        this.ctx.fillStyle = this.colorScheme.background;
-        this.ctx.fillRect(0, 0, this.width, this.height);
-
-        this.drawTiles();
-        this.drawGrid();
-        if (this.hasFocus) {
-            this.drawTileCursor();
-        }
-    }
-
-    drawGrid() {
-        this.ctx.beginPath();
-        for (let x = 0; x < this.tilemap.width; ++x) {
-            this.ctx.moveTo(x * this.tileSize, 0);
-            this.ctx.lineTo(x * this.tileSize, this.height);
-        }
-        for (let y = 0; y < this.tilemap.width; ++y) {
-            this.ctx.moveTo(0, y * this.tileSize);
-            this.ctx.lineTo(this.width, y * this.tileSize);
-        }
-        this.ctx.strokeStyle = this.colorScheme.grid;
-        this.ctx.lineWidth = 1;
-        this.ctx.stroke();
-    }
-
-    drawTileCursor() {
-        this.ctx.strokeStyle = this.colorScheme.tileCursor;
-        this.ctx.lineWidth = 5;
-        this.ctx.strokeRect(this.tileCursor.x * this.tileSize, this.tileCursor.y * this.tileSize, this.tileSize, this.tileSize);
-    }
-
-    get hasTilesets() {
-        // Remember that tile 0 represents emptiness.
-        return this.tilesets.length > 1 || this.tilesets47.length > 1;
-    }
-
-    drawTiles() {
-        if (this.hasTilesets) {
-            this.drawTexturedTiles();
-        } else {
-            this.drawColoredTiles();
-        }
-    }
-
-    drawColoredTiles() {
-        for (let y = 0; y < this.tilemap.height; ++y) {
-            for (let x = 0; x < this.tilemap.width; ++x) {
-                let tile = this.tilemap.at(x, y);
-                if (tile != 0) {
-                    this.ctx.fillStyle = this.colorScheme.tiles[tile];
-                    this.ctx.fillRect(x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize);
-                }
-            }
-        }
-    }
-
-    drawTexturedTiles() {
-        this.ctx.imageSmoothingEnabled = false;
-
-        for (let y = 0; y < this.tilemap.height; ++y) {
-            for (let x = 0; x < this.tilemap.width; ++x) {
-                let tile = this.tilemap.at(x, y);
-                if (tile != 0) {
-                    let tileset16 = this.tilesets[tile];
-                    let tileset47 = this.tilesets47[tile];
-                    let tileset = tileset47 != null ? tileset47 : tileset16;
-
-                    let tileIndex = 0;
-                    if (tileset47 != null) {
-                        let rawTileIndex = 0;
-                        rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y)) ? dirs47.E : 0;
-                        rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y + 1)) ? dirs47.SE : 0;
-                        rawTileIndex |= shouldConnect(tile, this.tilemap.at(x, y + 1)) ? dirs47.S : 0;
-                        rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y + 1)) ? dirs47.SW : 0;
-                        rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y)) ? dirs47.W : 0;
-                        rawTileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y - 1)) ? dirs47.NW : 0;
-                        rawTileIndex |= shouldConnect(tile, this.tilemap.at(x, y - 1)) ? dirs47.N : 0;
-                        rawTileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y - 1)) ? dirs47.NE : 0;
-                        tileIndex = connectionBitSetToX[removeRedundancies(rawTileIndex)];
-                    } else {
-                        tileIndex |= shouldConnect(tile, this.tilemap.at(x + 1, y)) ? 0b0001 : 0;
-                        tileIndex |= shouldConnect(tile, this.tilemap.at(x, y + 1)) ? 0b0010 : 0;
-                        tileIndex |= shouldConnect(tile, this.tilemap.at(x - 1, y)) ? 0b0100 : 0;
-                        tileIndex |= shouldConnect(tile, this.tilemap.at(x, y - 1)) ? 0b1000 : 0;
-                    }
-
-                    let tilesetTileSize = tileset.height;
-                    let tilesetX = tileIndex * tilesetTileSize;
-                    let tilesetY = 0;
-                    this.ctx.drawImage(
-                        tileset,
-                        tilesetX, tilesetY, tilesetTileSize, tilesetTileSize,
-                        x * this.tileSize, y * this.tileSize, this.tileSize, this.tileSize,
-                    );
-                }
-            }
-        }
-    }
-
-    mouseMoved(event) {
-        let mouse = this.getMousePositionFromEvent(event);
-        this.tileCursor.x = Math.floor(mouse.x / this.tileSize);
-        this.tileCursor.y = Math.floor(mouse.y / this.tileSize);
-        this.paintTileUnderCursor();
-    }
-
-    mousePressed(event) {
-        event.preventDefault();
-        if (event.button == 0) {
-            this.paintingTile = 1;
-        } else if (event.button == 2) {
-            this.paintingTile = 0;
-        }
-        this.paintTileUnderCursor();
-    }
-
-    mouseReleased() {
-        this.paintingTile = null;
-    }
-
-    paintTileUnderCursor() {
-        if (this.paintingTile != null) {
-            this.tilemap.setAt(this.tileCursor.x, this.tileCursor.y, this.paintingTile);
-        }
-    }
-}
-defineFrame("tairu-editor", TileEditor);
diff --git a/static/js/vendor/codejar.js b/static/js/vendor/codejar.js
index 95f0ad0..4041771 100644
--- a/static/js/vendor/codejar.js
+++ b/static/js/vendor/codejar.js
@@ -23,9 +23,10 @@ export function CodeJar(editor, highlight, opt = {}) {
     editor.setAttribute('contenteditable', 'plaintext-only');
     editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false');
     editor.style.outline = 'none';
-    editor.style.overflowWrap = 'break-word';
-    editor.style.overflowY = 'auto';
-    editor.style.whiteSpace = 'pre-wrap';
+    // PATCH(liquidex): I think I know better how I want to handle overflow.
+    // editor.style.overflowWrap = 'break-word';
+    // editor.style.overflowY = 'auto';
+    editor.style.whiteSpace = 'pre';
     const doHighlight = (editor, pos) => {
         highlight(editor, pos);
     };
diff --git a/static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png b/static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png
new file mode 100644
index 0000000..26ddac5
Binary files /dev/null and b/static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png differ
diff --git a/static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png b/static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png
new file mode 100644
index 0000000..88011c5
Binary files /dev/null and b/static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png differ
diff --git a/template/sandbox.hbs b/template/sandbox.hbs
index ca31a2c..64a8c49 100644
--- a/template/sandbox.hbs
+++ b/template/sandbox.hbs
@@ -9,6 +9,8 @@
         body {
             margin: 0;
             overflow: hidden;
+            width: fit-content;
+            height: fit-content;
         }
 
         canvas {
@@ -16,43 +18,37 @@
         }
     
 
-    
+