diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..ad92582
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+  "editor.formatOnSave": true
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..4261f3e
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,17 @@
+{
+  "version": "2.0.0",
+  "tasks": [
+    {
+      "label": "Serve Esbuild",
+      "detail": "Run a local server serving html files & transpiling TS -> JS",
+      "command": "npm",
+      "type": "process",
+      "args": ["run", "serve"],
+      "problemMatcher": ["$esbuild-watch"],
+      "presentation": {
+        "reveal": "always",
+        "panel": "dedicated"
+      }
+    }
+  ]
+}
diff --git a/debug.html b/debug.html
index c79ae55..7019993 100644
--- a/debug.html
+++ b/debug.html
@@ -6,6 +6,7 @@
     <script src="js/debug.js"></script>
     <script>
       document.body.append(...TickDebug());
+      document.body.append(...IdvDebug());
     </script>
   </body>
 </html>
diff --git a/debug.ts b/debug.ts
index 19908a0..b181e09 100644
--- a/debug.ts
+++ b/debug.ts
@@ -1,3 +1,4 @@
 import { TickDebug } from "./debug/tick";
+import { IdvDebug } from "./debug/idv";
 
-Object.assign(globalThis, { TickDebug });
+Object.assign(globalThis, { TickDebug, IdvDebug });
diff --git a/debug/idv.ts b/debug/idv.ts
new file mode 100644
index 0000000..f5447bb
--- /dev/null
+++ b/debug/idv.ts
@@ -0,0 +1,58 @@
+import { h } from "../lib/html";
+import { Idv, StringProperty } from "../lib/idv";
+
+export function IdvDebug() {
+  const textarea = h(
+    "textarea",
+    {
+      cols: 80,
+      rows: 40,
+      oninput(ev) {
+        parse();
+      },
+    },
+    `# Idv Testing Ground
+User: tangent128
+  Uid: 10000
+  Shell: tclsh
+  Group: users
+  Group: sudo
+  Banner:
+    +------------------+
+    |Welcome to Debian!|
+    +------------------+
+
+User: tirga
+  Uid: 10101
+  Shell: bash
+  Group: users
+`
+  );
+
+  const pre = h("pre");
+
+  function parse() {
+    try {
+      const idv = Idv.parse(textarea.value);
+
+      pre.textContent = JSON.stringify(
+        idv.getMergedMap("User", UserFromDocument),
+        null,
+        2
+      );
+    } catch (e) {
+      pre.textContent = String(e);
+    }
+  }
+  parse();
+
+  return [textarea, pre];
+}
+
+const UserFromDocument = (idv: Idv) => {
+  return {
+    shell: idv.getProperty("Shell", StringProperty),
+    groups: idv.getList("Group", StringProperty),
+    banner: idv.getProperty("Banner", StringProperty),
+  };
+};
diff --git a/idv.md b/idv.md
new file mode 100644
index 0000000..9ad7de1
--- /dev/null
+++ b/idv.md
@@ -0,0 +1,168 @@
+# The Indented Document Values Format
+
+## Overview
+
+The Indented Document Values (IDV) format is a meta-syntax for machine-readable textual data.
+
+IDV is designed to prioritize human readability and writability by minimizing visual noise- there are no sigils, quotes, or brackets, only colons, indentation, and (when necessary) backslash escapes.
+
+As a tradeoff, IDV is not a self-describing data format- while it can be used for defining a serialization or configuration format, systems using it need to layer their own semantics on top of it.
+
+### Example
+
+```
+Person: Alice
+  Uid: 1000
+  Phone: 555-1234
+  Group: users
+  Group: sudo
+  Banner:
+    ============================
+    This is my ASCII art login message
+    ============================
+
+Person: Bob
+  Uid: 1001
+  Phone: 555-5656
+  Group: users
+```
+
+## Syntax
+
+IDV is a line-oriented format. Before any other parsing is done, the input is split into lines, and any trailing whitespace on a line (including line separators) is ignored.
+
+### Comments
+
+A **Comment** is any line whose first character is a `#` character. Comment lines are for human use and are ignored by the parser.
+
+```
+# This line is ignored
+```
+
+### Blank Lines
+
+A **Blank Line** is any line that only contains whitespace. Because trailing whitespace is always trimmed, all Blank Lines are indistinguishable from each other.
+
+Blank Lines are ignored unless they are part of a Document. (see below)
+
+### Entries
+
+An **Entry** is composed of one or more lines:
+
+#### Tags
+
+Each entry begins with a **Tag**, terminated by a colon (`:`). A Tag can contain any characters except leading or trailing whitespace, newlines, and colons:
+
+```
+Tag:
+```
+
+#### Distinguishers
+
+Optionally, a Distinguisher can follow the Tag on the same line. A Distinguisher can contain any characters except leading or trailing whitespace, and newlines:
+
+```
+Tag: distinguisher
+```
+
+#### Escapes
+
+Within Tags and Distinguishers, backslash escapes may be used to represent non-permitted or inconvenient characters:
+
+```
+Tag With \: And Spaces:
+
+Tag: \ distinguisher with leading whitespace and\nA newline
+```
+
+| Escape sequence | Replacement       |
+| --------------- | ----------------- |
+| \\_\<space>_    | A literal space   |
+| \\n             | A newline         |
+| \\:             | A colon (`:`)     |
+| \\\\            | A backslash (`\`) |
+
+> TODO: additional escapes? ie, hex or unicode?
+
+#### Documents
+
+After the first line of an entry, any indented lines make up the **Document** portion of the entry:
+
+```
+Tag: distinguisher
+  First Line
+    Second Line
+  Third Line
+```
+
+The first line of a Document defines the Document's indentation- subsequent lines can be indented deeper, but no line may be indented _less_ than the first line. This indentation is removed from the beginning of each line when determining the Document's value.
+
+Blank Lines can not carry indentation information. To resolve this ambiguity, Documents may not begin or end with Blank Lines- such lines are ignored. Blank Lines that occur _between_ indented lines _are_ considered part of the Document.
+
+```
+Tag:
+
+  The above blank line is ignored.
+  The below blank line is part of the Document.
+
+  The below blank line is ignored.
+
+Tag:
+  Other stuff
+```
+
+Backslash escapes are _not_ processed within a Document. However, backslashes may be processed later, by higher-layered semantics.
+
+In many cases the Document will contain recursive IDV data, and the rules above are designed to play nicely with this case- but it is up to the concrete format to decide how to parse the Document. It could just as easily contain free text, XML, or a base64 blob.
+
+#### Disambiguations:
+
+1. The Tag and Distinguisher are both trimmed of surrounding whitespace before being interpreted, but internal whitespace is left intact.
+1. The Distinguisher may contain literal colons; these are treated as regular characters and carry no special meaning.
+
+## Data Model
+
+Applying minimal interpretation, IDV data can be represented as a list of Entries.
+
+An Entry can be represented as a 3-tuple of:
+
+1. a string (the Tag)
+2. a string (the optional Distinguisher)
+3. a list of strings (the lines of the Document)
+
+How Entries are interpreted by the appication is not specified, but see below for some suggested patterns that should line up with things people usually want to do.
+
+## Patterns
+
+### Primitive Property
+
+> TODO: one of distinguisher | document non-empty, parsing based on expected type
+
+### Object Property
+
+> TODO: distinguisher ignored, document is IDV
+
+### Union Property
+
+> TODO: distinguisher determines how the document is parsed
+
+### List
+
+> TODO: property specified multiple times
+
+### Map
+
+> TODO: distinguisher defines key, document parsed for value
+
+### Property Map
+
+> TODO: Category defines key, parsed as property for value
+
+### Merged Map
+
+## See Also
+
+> TODO:
+>
+> - yaml
+> - dpkg control files
diff --git a/js_sketch.code-workspace b/js_sketch.code-workspace
deleted file mode 100644
index 85ed6cf..0000000
--- a/js_sketch.code-workspace
+++ /dev/null
@@ -1,27 +0,0 @@
-{
-  "folders": [
-    {
-      "path": "."
-    }
-  ],
-  "settings": {
-    "editor.formatOnSave": true
-  },
-  "tasks": {
-    "version": "2.0.0",
-    "tasks": [
-      {
-        "label": "Serve Esbuild",
-        "detail": "Run a local server serving html files & transpiling TS -> JS",
-        "command": "npm",
-        "type": "process",
-        "args": ["run", "serve"],
-        "problemMatcher": ["$esbuild-watch"],
-        "presentation": {
-          "reveal": "always",
-          "panel": "dedicated"
-        }
-      }
-    ]
-  }
-}
diff --git a/lib/idv.ts b/lib/idv.ts
new file mode 100644
index 0000000..5cf7d2b
--- /dev/null
+++ b/lib/idv.ts
@@ -0,0 +1,125 @@
+export type DistinguisherParser<T> = (distinguisher: string) => T;
+export type DocumentParser<T> = (document: string[]) => T;
+export type UnionParser<T> = (distinguisher: string, document: string[]) => T;
+export type IdvConverter<T> = (idv: Idv) => T;
+
+const LEADING_WHITESPACE = /^([ \t]+)/;
+const ENTRY = /^(.+?):\s*(.*)/;
+
+export class Idv {
+  collections: Map<string, undefined | [string, string[]][]> = new Map();
+
+  public static parse(input: string): Idv {
+    const idv = new Idv();
+    return idv.import(input);
+  }
+  public static parseLines(input: string[]): Idv {
+    const idv = new Idv();
+    return idv.importLines(input);
+  }
+  public import(input: string): Idv {
+    const lines = input.split("\n").map((line) => line.trimEnd());
+    return this.importLines(lines);
+  }
+  public importLines(input: string[]): Idv {
+    let currentDocument: string[] | undefined = undefined;
+    let currentIndent: string | undefined = undefined;
+    let bufferedBlankLines: string[] = [];
+
+    input.forEach((line) => {
+      const indent = LEADING_WHITESPACE.exec(line)?.[1];
+      if (indent) {
+        if (currentDocument == undefined) {
+          throw new Error("Indented document found before an entry");
+        }
+        if (currentIndent == undefined) {
+          currentIndent = indent;
+        }
+        if (line.startsWith(currentIndent)) {
+          currentDocument.push(...bufferedBlankLines);
+          bufferedBlankLines = [];
+          currentDocument.push(line.substring(currentIndent.length));
+        } else {
+          throw new Error(
+            "Inconsistent indentation- line indented less than the first line of its document"
+          );
+        }
+      } else if (line == "") {
+        bufferedBlankLines.push("");
+      } else if (line[0] == "#") {
+        // skip
+      } else {
+        const matches = ENTRY.exec(line);
+        if (matches) {
+          const [, collection, distinguisher] = matches;
+
+          if (!this.collections.has(collection)) {
+            this.collections.set(collection, []);
+          }
+
+          currentDocument = [];
+          currentIndent = undefined;
+          // TODO: implement backslash escaping in the distinguisher
+          this.collections
+            .get(collection)!
+            .push([distinguisher, currentDocument]);
+        } else {
+          throw new Error("Failed to parse a property");
+        }
+      }
+    });
+
+    return this;
+  }
+
+  public getProperty<T>(name: string, parse: UnionParser<T>): T | undefined {
+    const firstEntry = this.collections.get(name)?.[0];
+    return firstEntry && parse(firstEntry[0], firstEntry[1]);
+  }
+
+  public getList<T>(name: string, parse: UnionParser<T>): T[] {
+    return (this.collections.get(name) ?? []).map(([distinguisher, document]) =>
+      parse(distinguisher, document)
+    );
+  }
+
+  public getMap<T>(
+    name: string,
+    parseDocument: DocumentParser<T>
+  ): Record<string, T> {
+    return Object.fromEntries(
+      (this.collections.get(name) ?? []).map(([distinguisher, document]) => [
+        distinguisher,
+        parseDocument(document),
+      ])
+    );
+  }
+
+  public getMergedMap<T>(
+    name: string,
+    convertIdv: IdvConverter<T>
+  ): Record<string, T> {
+    const idvMap: Map<string, Idv> = new Map();
+
+    (this.collections.get(name) ?? []).forEach(([distinguisher, document]) => {
+      let idv = idvMap.get(distinguisher);
+      if (idv == undefined) {
+        idvMap.set(distinguisher, (idv = new Idv()));
+      }
+
+      idv.importLines(document);
+    });
+
+    const result: Record<string, T> = {};
+
+    for (const [distinguisher, idv] of idvMap.entries()) {
+      result[distinguisher] = convertIdv(idv);
+    }
+
+    return result;
+  }
+}
+
+// TODO: implement backslash-escaping in the document?
+export const StringProperty = (distinguisher: string, lines: string[]) =>
+  lines.length == 0 ? distinguisher : lines.join("\n");
diff --git a/lib/keys.ts b/lib/keys.ts
new file mode 100644
index 0000000..c3b335f
--- /dev/null
+++ b/lib/keys.ts
@@ -0,0 +1,67 @@
+import { Source } from "./source";
+
+export type KeyName = "up" | "down" | "left" | "right" | "a" | "b" | "menu";
+
+const KEY_NAMES: Record<string, KeyName> = {
+  // compact keys (WASD+ZXC)
+  KeyZ: "a",
+  KeyX: "b",
+  KeyC: "menu",
+  KeyW: "up",
+  KeyS: "down",
+  KeyA: "left",
+  KeyD: "right",
+  // full-board keys (arrows+space/shift/enter)
+  Space: "a",
+  ShiftLeft: "b",
+  ShiftRight: "b",
+  Enter: "menu",
+  ArrowUp: "up",
+  ArrowDown: "down",
+  ArrowLeft: "left",
+  ArrowRight: "right",
+};
+
+/** A keypress/release event for an abstract button, or else ["focus", "release"] if for some reason future release events might not be registered. */
+export type KeyEvent = [KeyName | "focus", "press" | "release"];
+
+export function keyControl(source: HTMLElement): Source<KeyEvent> {
+  const tabIndex = source.getAttribute("tabIndex");
+  source.setAttribute(
+    "tabindex",
+    tabIndex == "" || tabIndex == null ? "-1" : tabIndex
+  );
+
+  return ((callback?: (keyEvent: KeyEvent) => void) => {
+    if (callback) {
+      const handle = (evt: KeyboardEvent, action: "press" | "release") => {
+        const keyName = KEY_NAMES[evt.code];
+        if (keyName != undefined) {
+          evt.preventDefault();
+          evt.stopPropagation();
+
+          callback([keyName, action]);
+        }
+      };
+      const keyUp = (evt: KeyboardEvent) => handle(evt, "release");
+      const keyDown = (evt: KeyboardEvent) => handle(evt, "press");
+      const focus = () => callback(["focus", "press"]);
+      const blur = () => callback(["focus", "release"]);
+
+      source.addEventListener("keyup", keyUp, false);
+      source.addEventListener("keydown", keyDown, false);
+      source.addEventListener("focus", focus, false);
+      source.addEventListener("blur", blur, false);
+      source.focus({ focusVisible: true } as FocusOptions);
+
+      return () => {
+        source.removeEventListener("keyup", keyUp, false);
+        source.removeEventListener("keydown", keyDown, false);
+        source.removeEventListener("focus", focus, false);
+        source.removeEventListener("blur", blur, false);
+      };
+    } else {
+      return ["blur", "release"];
+    }
+  }) as Source<KeyEvent>;
+}
diff --git a/lib/tick.ts b/lib/tick.ts
index a4b8c2b..d158357 100644
--- a/lib/tick.ts
+++ b/lib/tick.ts
@@ -1,13 +1,9 @@
-import { Cancel, Source } from "./source";
+import { Source } from "./source";
 
 export type PhysicsTick = ["physics"];
 export type RenderTick = ["render", number];
 
 export function tick(fps: number): Source<PhysicsTick | RenderTick> {
-  function tickSource(): PhysicsTick;
-  function tickSource(
-    callback: (tick: PhysicsTick | RenderTick) => void
-  ): Cancel;
   function tickSource(callback?: (tick: PhysicsTick | RenderTick) => void) {
     if (callback) {
       let lastPhysicsTick: number = new Date().getTime();
@@ -32,5 +28,5 @@ export function tick(fps: number): Source<PhysicsTick | RenderTick> {
     }
   }
 
-  return tickSource;
+  return tickSource as Source<PhysicsTick | RenderTick>;
 }
diff --git a/pixelflood.html b/pixelflood.html
new file mode 100644
index 0000000..a9d9d7f
--- /dev/null
+++ b/pixelflood.html
@@ -0,0 +1,11 @@
+<html>
+  <head>
+    <title>Pixelflood Art</title>
+  </head>
+  <body>
+    <script src="js/pixelflood.js"></script>
+    <script>
+      document.body.append(...PixelfloodApplet());
+    </script>
+  </body>
+</html>
diff --git a/pixelflood.ts b/pixelflood.ts
new file mode 100644
index 0000000..ba288a7
--- /dev/null
+++ b/pixelflood.ts
@@ -0,0 +1,45 @@
+// TODO choose generator
+// TODO get/put image data in render/tick loop
+// TODO rediscover RGB/HSV pixelflood techniques
+
+import { canvas2d, h } from "./lib/html";
+
+interface Controls {
+  seed: HTMLInputElement;
+}
+
+function PixelfloodApplet() {
+  const [canvas, cx] = canvas2d({});
+  const [controls, controlUi] = ControlUi();
+
+  return [canvas, ...controlUi];
+}
+
+function numInput(init: number) {
+  return h("input", { type: "number", valueAsNumber: init });
+}
+
+function ControlUi(): [Controls, HTMLElement[]] {
+  let seed, width, height;
+
+  const html = [
+    h("h2", {}, "Controls"),
+    h(
+      "div",
+      {},
+      h(
+        "label",
+        {},
+        "Width: ",
+        (width = numInput(128)),
+        "Height: ",
+        (height = numInput(128))
+      )
+    ),
+    h("div", {}, h("label", {}, "Random Seed: ", (seed = numInput(128)))),
+  ];
+
+  return [{ seed }, html];
+}
+
+Object.assign(globalThis, { PixelfloodApplet });
diff --git a/tsconfig.json b/tsconfig.json
index 41fc1da..d1ec214 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,6 +3,7 @@
     "isolatedModules": true,
     "lib": ["ES2019", "DOM"],
     "strict": true,
+    "target": "ES2020",
     "noEmit": true
   }
 }