From b506f5a219241a854a9660e8225cba202baf3493 Mon Sep 17 00:00:00 2001 From: lqdev Date: Sun, 18 Feb 2024 23:37:31 +0100 Subject: [PATCH] big commit --- content/about-treehouse/emoji.tree | 7 + content/programming/blog/tairu.tree | 394 ++++++++++++++---- jsconfig.json | 13 + static/css/main.css | 56 +-- static/css/tairu.css | 24 -- static/css/tree.css | 4 +- static/emoji/ahyes.png | Bin 0 -> 23255 bytes static/js/components/literate-programming.js | 167 ++++---- .../components/literate-programming/eval.js | 50 ++- .../components/literate-programming/worker.js | 23 - .../components/tairu/cardinal-directions.js | 0 static/js/components/tairu/editor.js | 134 ++++++ static/js/{ => components}/tairu/framework.js | 0 .../tairu/proto.js} | 47 ++- .../tairu/tilemap-registry.js | 0 static/js/{ => components}/tairu/tilemap.js | 12 + static/js/sandbox.js | 13 + static/js/tairu/tairu.js | 251 ----------- static/js/vendor/codejar.js | 7 +- .../pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png | Bin 0 -> 1100 bytes .../pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png | Bin 0 -> 1154 bytes template/sandbox.hbs | 46 +- 22 files changed, 692 insertions(+), 556 deletions(-) create mode 100644 jsconfig.json create mode 100644 static/emoji/ahyes.png delete mode 100644 static/js/components/literate-programming/worker.js create mode 100644 static/js/components/tairu/cardinal-directions.js create mode 100644 static/js/components/tairu/editor.js rename static/js/{ => components}/tairu/framework.js (100%) rename static/js/{tairu/cardinal-directions.js => components/tairu/proto.js} (61%) rename static/js/{ => components}/tairu/tilemap-registry.js (100%) rename static/js/{ => components}/tairu/tilemap.js (62%) delete mode 100644 static/js/tairu/tairu.js create mode 100644 static/pic/01HPYW5SNTY0Z0ENDE5K3XWMTH-goal.png create mode 100644 static/pic/01HPYWPJB1P0GK53BSJFJFRAGR-goal2.png 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: -
    -
  • - a 16-tile tileset of 8x8 pixel metal -
  • -
  • + ![heavy metal sheet tileset from Planet Overgamma, made out of 16 tiles. it looks like heavy embossed sheets of metal, resembling steel in its heavyness][pic:01HPHVDRV0F0251MD0A2EG66C4] + + [Planet Overgamma]: https://github.com/liquidev/planet-overgamma + + % classes.branch = "tileset-cardinal-directions-demo" + - we can split this tileset up into 16 individual tiles, each one 8 × 8 pixels; people choose various resolutions, I chose a fairly low one to hide my lack of artistic skill. + +
    + + + + + + + + + + + + + + + + +
    + + % classes.branch = "tileset-cardinal-directions-demo" + - the keen eyed among you have probably noticed that this is very similar to the case we had before with drawing procedural borders - + except that instead of determining which borders to draw based on a tile's neighbors, this time we'll determine which *whole tile* to draw based on its neighbors! + +
    ES ESW SW @@ -76,21 +229,28 @@ styles = ["tairu.css"] EW W -
  • -
+ - % 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 0000000000000000000000000000000000000000..8da75f84063cb34963b5191be17378921076b6c7
GIT binary patch
literal 23255
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
a3E+Q4uzIk3D3pZ&0000!~|&\.-]+/, 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 0000000000000000000000000000000000000000..26ddac59ee6422782e53ed287a44c100e13ab92f
GIT binary patch
literal 1100
zcmeAS@N?(olHy`uVBq!ia0y~yV9WrrPjIjS$)kPM{XmMbILO_JVcj{ImkbOnY@RNT
zAr*7p-f+x{_LOkD7_|6+v>k_JhWFkFIiAgG0h`S~IM`-y*yyBubX~SZI;;Gn>rVrh
zKeotI&N^rLtv25FvEJ`_$@h!jRQuK5mq`AzwTgk^y>Lc@Mk8B?(11}ZE`P1syH<|z
zK=$ozy_XARZaSQ|JihP!>hwEbMZd-BO}}dV!{K=0nt27+!++FI`QH1mlUc!g0plVT
zDTP;isuPXv%Q*X`N{^|Fl@9Fm+Z_AZ_UVA_M{f_K&mT#)>R3G0yf1hr|r+t;xo^LBx@5}F*
zxZnQwyvINvdO>_RfYiC-b62J7PVckNz84qfU$J!C^R<8W{rG$H`;Oe($13lCziRz%
zRx#gix$pNrzFnTrJ#A0^y||$2d#m0{CqH?Ae&6$oM+^g%s;t(_9eDoh#t6i`Nc7ReeL(3
z$zK^5?x|g12xQ_FP#rib`P|N`!=c~qF5maQ#%}$*g3!M|Z!hxtE{t!!rN5XxxKXrwQ-!ti;_-iKPh>(*9Q
z#@Rk>{rvR#E6(t%^R8!q{SEYCa!>A?-}|bLFSLA~cK_`?t9^eLeqNjzH*fp;&%a+u
zwm*An`E}2|p#Ap0-(B8k*KmJ^1G5XKiNlnEqk1i$zv2zQ`91sW>Cdk?^{;%}Gwr@O
z(0zI4$8H?k_kMM`3@{mfyZdhKanf7oaD|Hrl}rVsYe)BuZ}2N8e&z1e+NB3T-kgYFe(oZES`e7EKEGOM{^=PkYn
z&YNGmee?Uee~%ajA_<`;S(uaGCEu@ov%9}`yL58Sf2Or=jOWXk%FX}_6$VdNKbLh*
G2~7a|xXP#i

literal 0
HcmV?d00001

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 @@
         }
     
 
-    
+