{name}
#{sig}'
+ '{desc}
{ex}diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml
new file mode 100644
index 00000000..60b78746
--- /dev/null
+++ b/.forgejo/workflows/docs.yml
@@ -0,0 +1,55 @@
+name: docs
+
+# Rebuild the documentation site and publish it to the `pages` branch on every
+# push to main that touches the docs sources. The site is generated from the
+# per-symbol source of truth in docs/language/** — nothing is hand-edited on the
+# pages branch.
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'docs/**'
+ - 'tools/docgen/**'
+ - '.forgejo/workflows/docs.yml'
+ workflow_dispatch: {}
+
+permissions:
+ contents: write
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Generate the documentation site
+ run: |
+ python3 --version
+ python3 tools/docgen/gen.py --out public
+ echo "--- generated files ---"
+ ls -la public
+
+ - name: Sanity-check the output (root has index.html + .nojekyll, no broken links)
+ run: python3 tools/docgen/check.py public
+
+ - name: Publish to the pages branch
+ run: |
+ set -eu
+ TOKEN="${PAGES_TOKEN:-${AUTO_TOKEN:-}}"
+ if [ -z "$TOKEN" ]; then
+ echo "::error::No deploy token. Add a repo secret PAGES_TOKEN (write access) or allow the automatic token to push."
+ exit 1
+ fi
+ cd public
+ git init -q -b pages
+ git config user.name "ludic-docs-bot"
+ git config user.email "docs@workshopsoft.io"
+ git add -A
+ git commit -q -m "docs: regenerate site from ${SOURCE_SHA}"
+ git push -f "https://ludic-docs-bot:${TOKEN}@git.workshopsoft.io/workshopsoft/ludic.git" pages
+ echo "published $(git rev-parse --short HEAD) to pages"
+ env:
+ PAGES_TOKEN: ${{ secrets.PAGES_TOKEN }}
+ AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SOURCE_SHA: ${{ github.sha }}
diff --git a/docs/language/annotations/_section.md b/docs/language/annotations/_section.md
new file mode 100644
index 00000000..5da3cb23
--- /dev/null
+++ b/docs/language/annotations/_section.md
@@ -0,0 +1,7 @@
+---
+id: annotations
+title: Annotations
+order: 13
+---
+
+@Name(...) decorators attach compile-time behavior to a handler, property, or function.
diff --git a/docs/language/annotations/annot-computed.md b/docs/language/annotations/annot-computed.md
new file mode 100644
index 00000000..5bcb92cf
--- /dev/null
+++ b/docs/language/annotations/annot-computed.md
@@ -0,0 +1,12 @@
+---
+id: annot-computed
+name: @Computed
+category: annotations
+kind: annotation
+tokens: @Computed
+sig: @Computed on a property field
+tip: A derived field: an expression expanded inline wherever it is read, never stored.
+order: 1
+---
+
+A derived field: an expression expanded inline wherever it is read, never stored.
diff --git a/docs/language/annotations/annot-export.md b/docs/language/annotations/annot-export.md
new file mode 100644
index 00000000..79a496ce
--- /dev/null
+++ b/docs/language/annotations/annot-export.md
@@ -0,0 +1,12 @@
+---
+id: annot-export
+name: @export
+category: annotations
+kind: annotation
+tokens: @export
+sig: @export fn name(…) -> R
+tip: Expose a function as a C-ABI symbol from a module.
+order: 3
+---
+
+Expose a function as a C-ABI symbol from a module.
diff --git a/docs/language/annotations/annot-on.md b/docs/language/annotations/annot-on.md
new file mode 100644
index 00000000..acb4f5a6
--- /dev/null
+++ b/docs/language/annotations/annot-on.md
@@ -0,0 +1,12 @@
+---
+id: annot-on
+name: @OnEnable / @OnDisable / @OnDespawn
+category: annotations
+kind: annotation
+tokens: @OnEnable @OnDisable @OnDespawn
+sig: @OnDisable(Prop) handler … { … }
+tip: Lifecycle hooks that fire when a property/model is toggled or an entity is torn down.
+order: 2
+---
+
+Lifecycle hooks that fire when a property/model is toggled or an entity is torn down.
diff --git a/docs/language/annotations/annot-queries.md b/docs/language/annotations/annot-queries.md
new file mode 100644
index 00000000..1ef62402
--- /dev/null
+++ b/docs/language/annotations/annot-queries.md
@@ -0,0 +1,12 @@
+---
+id: annot-queries
+name: @Queries
+category: annotations
+kind: annotation
+tokens: @Queries
+sig: @Queries(these: [Pos, Vel])
+tip: Declare the properties a handler touches, binding their fields by name in the body.
+order: 0
+---
+
+Declare the properties a handler touches, binding their fields by name in the body.
diff --git a/docs/language/annotations/annot-sync.md b/docs/language/annotations/annot-sync.md
new file mode 100644
index 00000000..a01629bb
--- /dev/null
+++ b/docs/language/annotations/annot-sync.md
@@ -0,0 +1,12 @@
+---
+id: annot-sync
+name: @Sync / @Owned
+category: annotations
+kind: annotation
+tokens: @Sync @Owned
+sig: @Sync property … / @Owned
+tip: Mark a property's fields as replicated, and entities as owned, for networking.
+order: 4
+---
+
+Mark a property's fields as replicated, and entities as owned, for networking.
diff --git a/docs/language/builtins/_section.md b/docs/language/builtins/_section.md
new file mode 100644
index 00000000..d26212f5
--- /dev/null
+++ b/docs/language/builtins/_section.md
@@ -0,0 +1,7 @@
+---
+id: builtins
+title: Builtin functions
+order: 12
+---
+
+Global functions available anywhere, beyond the namespaced APIs above.
diff --git a/docs/language/builtins/fn-abs.md b/docs/language/builtins/fn-abs.md
new file mode 100644
index 00000000..e5907fd9
--- /dev/null
+++ b/docs/language/builtins/fn-abs.md
@@ -0,0 +1,12 @@
+---
+id: fn-abs
+name: abs
+category: builtins
+kind: builtin
+tokens: abs
+sig: abs(a) -> int
+tip: Absolute value.
+order: 8
+---
+
+Absolute value.
diff --git a/docs/language/builtins/fn-bytes.md b/docs/language/builtins/fn-bytes.md
new file mode 100644
index 00000000..97f2fb92
--- /dev/null
+++ b/docs/language/builtins/fn-bytes.md
@@ -0,0 +1,12 @@
+---
+id: fn-bytes
+name: bytes / words
+category: builtins
+kind: builtin
+tokens: bytes words
+sig: bytes(n) -> ptr / words(n) -> words
+tip: Allocate a raw buffer of n bytes / n 32-bit words.
+order: 11
+---
+
+Allocate a raw buffer of n bytes / n 32-bit words.
diff --git a/docs/language/builtins/fn-clamp.md b/docs/language/builtins/fn-clamp.md
new file mode 100644
index 00000000..69a0ec43
--- /dev/null
+++ b/docs/language/builtins/fn-clamp.md
@@ -0,0 +1,12 @@
+---
+id: fn-clamp
+name: clamp
+category: builtins
+kind: builtin
+tokens: clamp
+sig: clamp(v, lo, hi) -> int
+tip: Constrain v to [lo, hi].
+order: 9
+---
+
+Constrain v to [lo, hi].
diff --git a/docs/language/builtins/fn-font_load.md b/docs/language/builtins/fn-font_load.md
new file mode 100644
index 00000000..21b8833f
--- /dev/null
+++ b/docs/language/builtins/fn-font_load.md
@@ -0,0 +1,12 @@
+---
+id: fn-font_load
+name: font_load
+category: builtins
+kind: builtin
+tokens: font_load
+sig: font_load(path) -> int
+tip: Load a TrueType font and return a handle for the UI.
+order: 12
+---
+
+Load a TrueType font and return a handle for the UI.
diff --git a/docs/language/builtins/fn-fx.md b/docs/language/builtins/fn-fx.md
new file mode 100644
index 00000000..cd6067bd
--- /dev/null
+++ b/docs/language/builtins/fn-fx.md
@@ -0,0 +1,12 @@
+---
+id: fn-fx
+name: fx / flr
+category: builtins
+kind: builtin
+tokens: fx flr
+sig: fx(n) -> fixed / flr(x) -> int
+tip: Convert between int and fixed-point.
+order: 10
+---
+
+Convert between int and fixed-point.
diff --git a/docs/language/builtins/fn-len.md b/docs/language/builtins/fn-len.md
new file mode 100644
index 00000000..9b4c5080
--- /dev/null
+++ b/docs/language/builtins/fn-len.md
@@ -0,0 +1,12 @@
+---
+id: fn-len
+name: len
+category: builtins
+kind: builtin
+tokens: len
+sig: len(x) -> int
+tip: Length of a slice or string.
+order: 5
+---
+
+Length of a slice or string.
diff --git a/docs/language/builtins/fn-load.md b/docs/language/builtins/fn-load.md
new file mode 100644
index 00000000..ce938ad3
--- /dev/null
+++ b/docs/language/builtins/fn-load.md
@@ -0,0 +1,12 @@
+---
+id: fn-load
+name: load
+category: builtins
+kind: builtin
+tokens: load
+sig: load() -> bool
+tip: Restore a snapshot written by save().
+order: 4
+---
+
+Restore a snapshot written by save().
diff --git a/docs/language/builtins/fn-min.md b/docs/language/builtins/fn-min.md
new file mode 100644
index 00000000..b215df4b
--- /dev/null
+++ b/docs/language/builtins/fn-min.md
@@ -0,0 +1,12 @@
+---
+id: fn-min
+name: min / max
+category: builtins
+kind: builtin
+tokens: min max
+sig: min(a, b) / max(a, b) -> int
+tip: The smaller / larger of two ints.
+order: 7
+---
+
+The smaller / larger of two ints.
diff --git a/docs/language/builtins/fn-print.md b/docs/language/builtins/fn-print.md
new file mode 100644
index 00000000..f0f96104
--- /dev/null
+++ b/docs/language/builtins/fn-print.md
@@ -0,0 +1,12 @@
+---
+id: fn-print
+name: print
+category: builtins
+kind: builtin
+tokens: print
+sig: print(x)
+tip: Print an int or string, followed by a newline — for headless tests and debugging.
+order: 0
+---
+
+Print an int or string, followed by a newline — for headless tests and debugging.
diff --git a/docs/language/builtins/fn-push.md b/docs/language/builtins/fn-push.md
new file mode 100644
index 00000000..164eab50
--- /dev/null
+++ b/docs/language/builtins/fn-push.md
@@ -0,0 +1,12 @@
+---
+id: fn-push
+name: push
+category: builtins
+kind: builtin
+tokens: push
+sig: push(slice, x)
+tip: Append to a slice.
+order: 6
+---
+
+Append to a slice.
diff --git a/docs/language/builtins/fn-quit.md b/docs/language/builtins/fn-quit.md
new file mode 100644
index 00000000..8f97bc90
--- /dev/null
+++ b/docs/language/builtins/fn-quit.md
@@ -0,0 +1,12 @@
+---
+id: fn-quit
+name: quit
+category: builtins
+kind: builtin
+tokens: quit
+sig: quit()
+tip: Stop the game loop after this frame.
+order: 2
+---
+
+Stop the game loop after this frame.
diff --git a/docs/language/builtins/fn-save.md b/docs/language/builtins/fn-save.md
new file mode 100644
index 00000000..545b5808
--- /dev/null
+++ b/docs/language/builtins/fn-save.md
@@ -0,0 +1,12 @@
+---
+id: fn-save
+name: save
+category: builtins
+kind: builtin
+tokens: save
+sig: save()
+tip: Serialize the entire world — every entity, property and program var — to a snapshot in one call.
+order: 3
+---
+
+Serialize the entire world — every entity, property and program var — to a snapshot in one call.
diff --git a/docs/language/builtins/fn-str.md b/docs/language/builtins/fn-str.md
new file mode 100644
index 00000000..96f41bfc
--- /dev/null
+++ b/docs/language/builtins/fn-str.md
@@ -0,0 +1,12 @@
+---
+id: fn-str
+name: str
+category: builtins
+kind: builtin
+tokens: str
+sig: str(x) -> str
+tip: Convert an int/bool/fixed to text; a string passes through.
+order: 1
+---
+
+Convert an int/bool/fixed to text; a string passes through. Also used by `{…}` interpolation.
diff --git a/docs/language/colors/_section.md b/docs/language/colors/_section.md
new file mode 100644
index 00000000..e7937c23
--- /dev/null
+++ b/docs/language/colors/_section.md
@@ -0,0 +1,7 @@
+---
+id: colors
+title: Color — the named palette
+order: 9
+---
+
+Color.Name lowers to a plain 0xRRGGBB integer at compile time — no runtime cost, identical to writing the hex by hand, but readable. 221 names are built in; a custom shade is any hex literal or a named const. The full palette:
diff --git a/docs/language/colors/palette.json b/docs/language/colors/palette.json
new file mode 100644
index 00000000..0f37ffeb
--- /dev/null
+++ b/docs/language/colors/palette.json
@@ -0,0 +1,944 @@
+{
+ "count": 221,
+ "groups": [
+ {
+ "name": "Whites",
+ "colors": [
+ {
+ "name": "White",
+ "hex": "FFFFFF"
+ },
+ {
+ "name": "Snow",
+ "hex": "FFFAFA"
+ },
+ {
+ "name": "Ivory",
+ "hex": "FFFFF0"
+ },
+ {
+ "name": "EggShellWhite",
+ "hex": "F0EAD6"
+ },
+ {
+ "name": "FloralWhite",
+ "hex": "FFFAF0"
+ },
+ {
+ "name": "SeaShell",
+ "hex": "FFF5EE"
+ },
+ {
+ "name": "Linen",
+ "hex": "FAF0E6"
+ },
+ {
+ "name": "AntiqueWhite",
+ "hex": "FAEBD7"
+ },
+ {
+ "name": "OldLace",
+ "hex": "FDF5E6"
+ },
+ {
+ "name": "Beige",
+ "hex": "F5F5DC"
+ },
+ {
+ "name": "Cream",
+ "hex": "FFFDD0"
+ },
+ {
+ "name": "Honeydew",
+ "hex": "F0FFF0"
+ },
+ {
+ "name": "MintCream",
+ "hex": "F5FFFA"
+ },
+ {
+ "name": "Azure",
+ "hex": "F0FFFF"
+ },
+ {
+ "name": "AliceBlue",
+ "hex": "F0F8FF"
+ },
+ {
+ "name": "GhostWhite",
+ "hex": "F8F8FF"
+ },
+ {
+ "name": "WhiteSmoke",
+ "hex": "F5F5F5"
+ },
+ {
+ "name": "Lavender",
+ "hex": "E6E6FA"
+ },
+ {
+ "name": "Bone",
+ "hex": "E3DAC9"
+ },
+ {
+ "name": "Parchment",
+ "hex": "F1E9D2"
+ }
+ ]
+ },
+ {
+ "name": "Grays",
+ "colors": [
+ {
+ "name": "Gainsboro",
+ "hex": "DCDCDC"
+ },
+ {
+ "name": "LightGray",
+ "hex": "D3D3D3"
+ },
+ {
+ "name": "Silver",
+ "hex": "C0C0C0"
+ },
+ {
+ "name": "Ash",
+ "hex": "B2BEB5"
+ },
+ {
+ "name": "DarkGray",
+ "hex": "A9A9A9"
+ },
+ {
+ "name": "Gray",
+ "hex": "808080"
+ },
+ {
+ "name": "DimGray",
+ "hex": "696969"
+ },
+ {
+ "name": "Nickel",
+ "hex": "727472"
+ },
+ {
+ "name": "Slate",
+ "hex": "708090"
+ },
+ {
+ "name": "SlateGray",
+ "hex": "708090"
+ },
+ {
+ "name": "LightSlateGray",
+ "hex": "778899"
+ },
+ {
+ "name": "Gunmetal",
+ "hex": "2A3439"
+ },
+ {
+ "name": "Charcoal",
+ "hex": "36454F"
+ },
+ {
+ "name": "Graphite",
+ "hex": "1C1C1C"
+ },
+ {
+ "name": "Onyx",
+ "hex": "353839"
+ },
+ {
+ "name": "Jet",
+ "hex": "343434"
+ },
+ {
+ "name": "Black",
+ "hex": "000000"
+ },
+ {
+ "name": "EerieBlack",
+ "hex": "1B1B1B"
+ },
+ {
+ "name": "RaisinBlack",
+ "hex": "242124"
+ },
+ {
+ "name": "Ebony",
+ "hex": "555D50"
+ }
+ ]
+ },
+ {
+ "name": "Reds",
+ "colors": [
+ {
+ "name": "Red",
+ "hex": "FF0000"
+ },
+ {
+ "name": "Crimson",
+ "hex": "DC143C"
+ },
+ {
+ "name": "Scarlet",
+ "hex": "FF2400"
+ },
+ {
+ "name": "Vermilion",
+ "hex": "E34234"
+ },
+ {
+ "name": "FireBrick",
+ "hex": "B22222"
+ },
+ {
+ "name": "Cinnabar",
+ "hex": "E44D2E"
+ },
+ {
+ "name": "DarkRed",
+ "hex": "8B0000"
+ },
+ {
+ "name": "Maroon",
+ "hex": "800000"
+ },
+ {
+ "name": "Ruby",
+ "hex": "E0115F"
+ },
+ {
+ "name": "Cardinal",
+ "hex": "C41E3A"
+ },
+ {
+ "name": "IndianRed",
+ "hex": "CD5C5C"
+ },
+ {
+ "name": "Rust",
+ "hex": "B7410E"
+ },
+ {
+ "name": "Sangria",
+ "hex": "92000A"
+ },
+ {
+ "name": "Redwood",
+ "hex": "A45A52"
+ },
+ {
+ "name": "Cerise",
+ "hex": "DE3163"
+ },
+ {
+ "name": "Amaranth",
+ "hex": "E52B50"
+ },
+ {
+ "name": "Carmine",
+ "hex": "960018"
+ },
+ {
+ "name": "Chestnut",
+ "hex": "954535"
+ },
+ {
+ "name": "Brick",
+ "hex": "CB4154"
+ },
+ {
+ "name": "TerraCotta",
+ "hex": "E2725B"
+ }
+ ]
+ },
+ {
+ "name": "Pinks",
+ "colors": [
+ {
+ "name": "Pink",
+ "hex": "FFC0CB"
+ },
+ {
+ "name": "LightPink",
+ "hex": "FFB6C1"
+ },
+ {
+ "name": "HotPink",
+ "hex": "FF69B4"
+ },
+ {
+ "name": "DeepPink",
+ "hex": "FF1493"
+ },
+ {
+ "name": "PaleVioletRed",
+ "hex": "DB7093"
+ },
+ {
+ "name": "Rose",
+ "hex": "FF007F"
+ },
+ {
+ "name": "Blush",
+ "hex": "DE5D83"
+ },
+ {
+ "name": "Salmon",
+ "hex": "FA8072"
+ },
+ {
+ "name": "LightSalmon",
+ "hex": "FFA07A"
+ },
+ {
+ "name": "DarkSalmon",
+ "hex": "E9967A"
+ },
+ {
+ "name": "Coral",
+ "hex": "FF7F50"
+ },
+ {
+ "name": "Watermelon",
+ "hex": "FC6C85"
+ },
+ {
+ "name": "Flamingo",
+ "hex": "FC8EAC"
+ },
+ {
+ "name": "Bubblegum",
+ "hex": "FFC1CC"
+ },
+ {
+ "name": "Fuchsia",
+ "hex": "FF00FF"
+ },
+ {
+ "name": "Magenta",
+ "hex": "FF00FF"
+ },
+ {
+ "name": "Mauve",
+ "hex": "E0B0FF"
+ },
+ {
+ "name": "Puce",
+ "hex": "CC8899"
+ },
+ {
+ "name": "Thistle",
+ "hex": "D8BFD8"
+ },
+ {
+ "name": "Orchid",
+ "hex": "DA70D6"
+ }
+ ]
+ },
+ {
+ "name": "Oranges",
+ "colors": [
+ {
+ "name": "Orange",
+ "hex": "FFA500"
+ },
+ {
+ "name": "DarkOrange",
+ "hex": "FF8C00"
+ },
+ {
+ "name": "Tangerine",
+ "hex": "F28500"
+ },
+ {
+ "name": "Pumpkin",
+ "hex": "FF7518"
+ },
+ {
+ "name": "Apricot",
+ "hex": "FBCEB1"
+ },
+ {
+ "name": "Peach",
+ "hex": "FFE5B4"
+ },
+ {
+ "name": "Cantaloupe",
+ "hex": "FFA62B"
+ },
+ {
+ "name": "Amber",
+ "hex": "FFBF00"
+ },
+ {
+ "name": "Bronze",
+ "hex": "CD7F32"
+ },
+ {
+ "name": "Copper",
+ "hex": "B87333"
+ },
+ {
+ "name": "Marigold",
+ "hex": "EAA221"
+ },
+ {
+ "name": "Carrot",
+ "hex": "ED9121"
+ },
+ {
+ "name": "Persimmon",
+ "hex": "EC5800"
+ },
+ {
+ "name": "Papaya",
+ "hex": "FF9E2C"
+ },
+ {
+ "name": "Sunset",
+ "hex": "FAD6A5"
+ }
+ ]
+ },
+ {
+ "name": "Yellows",
+ "colors": [
+ {
+ "name": "Yellow",
+ "hex": "FFFF00"
+ },
+ {
+ "name": "LightYellow",
+ "hex": "FFFFE0"
+ },
+ {
+ "name": "Gold",
+ "hex": "FFD700"
+ },
+ {
+ "name": "Goldenrod",
+ "hex": "DAA520"
+ },
+ {
+ "name": "Lemon",
+ "hex": "FFF700"
+ },
+ {
+ "name": "Canary",
+ "hex": "FFEF00"
+ },
+ {
+ "name": "Mustard",
+ "hex": "FFDB58"
+ },
+ {
+ "name": "Flax",
+ "hex": "EEDC82"
+ },
+ {
+ "name": "Wheat",
+ "hex": "F5DEB3"
+ },
+ {
+ "name": "Corn",
+ "hex": "FBEC5D"
+ },
+ {
+ "name": "Dandelion",
+ "hex": "F0E130"
+ },
+ {
+ "name": "Saffron",
+ "hex": "F4C430"
+ },
+ {
+ "name": "Khaki",
+ "hex": "F0E68C"
+ },
+ {
+ "name": "DarkKhaki",
+ "hex": "BDB76B"
+ },
+ {
+ "name": "Straw",
+ "hex": "E4D96F"
+ }
+ ]
+ },
+ {
+ "name": "Browns",
+ "colors": [
+ {
+ "name": "Brown",
+ "hex": "8B4513"
+ },
+ {
+ "name": "SaddleBrown",
+ "hex": "8B4513"
+ },
+ {
+ "name": "Sienna",
+ "hex": "A0522D"
+ },
+ {
+ "name": "Chocolate",
+ "hex": "D2691E"
+ },
+ {
+ "name": "Peru",
+ "hex": "CD853F"
+ },
+ {
+ "name": "Tan",
+ "hex": "D2B48C"
+ },
+ {
+ "name": "BurlyWood",
+ "hex": "DEB887"
+ },
+ {
+ "name": "Sand",
+ "hex": "C2B280"
+ },
+ {
+ "name": "Coffee",
+ "hex": "6F4E37"
+ },
+ {
+ "name": "Espresso",
+ "hex": "4B3621"
+ },
+ {
+ "name": "Mahogany",
+ "hex": "C04000"
+ },
+ {
+ "name": "Walnut",
+ "hex": "773F1A"
+ },
+ {
+ "name": "Umber",
+ "hex": "635147"
+ },
+ {
+ "name": "Sepia",
+ "hex": "704214"
+ },
+ {
+ "name": "Taupe",
+ "hex": "483C32"
+ },
+ {
+ "name": "Fawn",
+ "hex": "E5AA70"
+ },
+ {
+ "name": "Caramel",
+ "hex": "C68E17"
+ },
+ {
+ "name": "Cocoa",
+ "hex": "D2691E"
+ },
+ {
+ "name": "Hazel",
+ "hex": "8E7618"
+ },
+ {
+ "name": "Wenge",
+ "hex": "645452"
+ }
+ ]
+ },
+ {
+ "name": "Greens",
+ "colors": [
+ {
+ "name": "Green",
+ "hex": "008000"
+ },
+ {
+ "name": "Lime",
+ "hex": "00FF00"
+ },
+ {
+ "name": "LimeGreen",
+ "hex": "32CD32"
+ },
+ {
+ "name": "LawnGreen",
+ "hex": "7CFC00"
+ },
+ {
+ "name": "Chartreuse",
+ "hex": "7FFF00"
+ },
+ {
+ "name": "GreenYellow",
+ "hex": "ADFF2F"
+ },
+ {
+ "name": "SpringGreen",
+ "hex": "00FF7F"
+ },
+ {
+ "name": "MintGreen",
+ "hex": "98FF98"
+ },
+ {
+ "name": "SeaGreen",
+ "hex": "2E8B57"
+ },
+ {
+ "name": "MediumSeaGreen",
+ "hex": "3CB371"
+ },
+ {
+ "name": "ForestGreen",
+ "hex": "228B22"
+ },
+ {
+ "name": "DarkGreen",
+ "hex": "006400"
+ },
+ {
+ "name": "OliveDrab",
+ "hex": "6B8E23"
+ },
+ {
+ "name": "Olive",
+ "hex": "808000"
+ },
+ {
+ "name": "Moss",
+ "hex": "8A9A5B"
+ },
+ {
+ "name": "Fern",
+ "hex": "4F7942"
+ },
+ {
+ "name": "Emerald",
+ "hex": "50C878"
+ },
+ {
+ "name": "Jade",
+ "hex": "00A86B"
+ },
+ {
+ "name": "Malachite",
+ "hex": "0BDA51"
+ },
+ {
+ "name": "Shamrock",
+ "hex": "009E60"
+ },
+ {
+ "name": "Pistachio",
+ "hex": "93C572"
+ },
+ {
+ "name": "Avocado",
+ "hex": "568203"
+ },
+ {
+ "name": "Pine",
+ "hex": "01796F"
+ },
+ {
+ "name": "Sage",
+ "hex": "9CAF88"
+ },
+ {
+ "name": "Kelly",
+ "hex": "4CBB17"
+ },
+ {
+ "name": "Hunter",
+ "hex": "355E3B"
+ },
+ {
+ "name": "Basil",
+ "hex": "579229"
+ },
+ {
+ "name": "Clover",
+ "hex": "2E8B57"
+ },
+ {
+ "name": "Juniper",
+ "hex": "6D9A79"
+ },
+ {
+ "name": "Neon",
+ "hex": "39FF14"
+ }
+ ]
+ },
+ {
+ "name": "Cyans",
+ "colors": [
+ {
+ "name": "Cyan",
+ "hex": "00FFFF"
+ },
+ {
+ "name": "Aqua",
+ "hex": "00FFFF"
+ },
+ {
+ "name": "LightCyan",
+ "hex": "E0FFFF"
+ },
+ {
+ "name": "PaleTurquoise",
+ "hex": "AFEEEE"
+ },
+ {
+ "name": "Aquamarine",
+ "hex": "7FFFD4"
+ },
+ {
+ "name": "Turquoise",
+ "hex": "40E0D0"
+ },
+ {
+ "name": "MediumTurquoise",
+ "hex": "48D1CC"
+ },
+ {
+ "name": "DarkTurquoise",
+ "hex": "00CED1"
+ },
+ {
+ "name": "Teal",
+ "hex": "008080"
+ },
+ {
+ "name": "DarkCyan",
+ "hex": "008B8B"
+ },
+ {
+ "name": "CadetBlue",
+ "hex": "5F9EA0"
+ },
+ {
+ "name": "Lagoon",
+ "hex": "018E8E"
+ },
+ {
+ "name": "Seafoam",
+ "hex": "93E9BE"
+ },
+ {
+ "name": "Cerulean",
+ "hex": "007BA7"
+ },
+ {
+ "name": "SkyBlueLight",
+ "hex": "80DAEB"
+ },
+ {
+ "name": "Robin",
+ "hex": "00CCCC"
+ },
+ {
+ "name": "Verdigris",
+ "hex": "43B3AE"
+ },
+ {
+ "name": "Celadon",
+ "hex": "ACE1AF"
+ }
+ ]
+ },
+ {
+ "name": "Blues",
+ "colors": [
+ {
+ "name": "Blue",
+ "hex": "0000FF"
+ },
+ {
+ "name": "LightBlue",
+ "hex": "ADD8E6"
+ },
+ {
+ "name": "PowderBlue",
+ "hex": "B0E0E6"
+ },
+ {
+ "name": "SkyBlue",
+ "hex": "87CEEB"
+ },
+ {
+ "name": "LightSkyBlue",
+ "hex": "87CEFA"
+ },
+ {
+ "name": "DeepSkyBlue",
+ "hex": "00BFFF"
+ },
+ {
+ "name": "DodgerBlue",
+ "hex": "1E90FF"
+ },
+ {
+ "name": "CornflowerBlue",
+ "hex": "6495ED"
+ },
+ {
+ "name": "SteelBlue",
+ "hex": "4682B4"
+ },
+ {
+ "name": "RoyalBlue",
+ "hex": "4169E1"
+ },
+ {
+ "name": "MediumBlue",
+ "hex": "0000CD"
+ },
+ {
+ "name": "DarkBlue",
+ "hex": "00008B"
+ },
+ {
+ "name": "Navy",
+ "hex": "000080"
+ },
+ {
+ "name": "MidnightBlue",
+ "hex": "191970"
+ },
+ {
+ "name": "Cobalt",
+ "hex": "0047AB"
+ },
+ {
+ "name": "Sapphire",
+ "hex": "0F52BA"
+ },
+ {
+ "name": "Denim",
+ "hex": "1560BD"
+ },
+ {
+ "name": "Indigo",
+ "hex": "4B0082"
+ },
+ {
+ "name": "Prussian",
+ "hex": "003153"
+ },
+ {
+ "name": "Ultramarine",
+ "hex": "3F00FF"
+ },
+ {
+ "name": "Periwinkle",
+ "hex": "CCCCFF"
+ },
+ {
+ "name": "Iris",
+ "hex": "5A4FCF"
+ },
+ {
+ "name": "Glaucous",
+ "hex": "6082B6"
+ },
+ {
+ "name": "Zaffre",
+ "hex": "0014A8"
+ },
+ {
+ "name": "Berry",
+ "hex": "2E2D88"
+ }
+ ]
+ },
+ {
+ "name": "Purples",
+ "colors": [
+ {
+ "name": "Purple",
+ "hex": "800080"
+ },
+ {
+ "name": "Violet",
+ "hex": "EE82EE"
+ },
+ {
+ "name": "DarkViolet",
+ "hex": "9400D3"
+ },
+ {
+ "name": "BlueViolet",
+ "hex": "8A2BE2"
+ },
+ {
+ "name": "MediumPurple",
+ "hex": "9370DB"
+ },
+ {
+ "name": "Amethyst",
+ "hex": "9966CC"
+ },
+ {
+ "name": "Plum",
+ "hex": "8E4585"
+ },
+ {
+ "name": "Eggplant",
+ "hex": "614051"
+ },
+ {
+ "name": "Grape",
+ "hex": "6F2DA8"
+ },
+ {
+ "name": "Wine",
+ "hex": "722F37"
+ },
+ {
+ "name": "Mulberry",
+ "hex": "C54B8C"
+ },
+ {
+ "name": "Lilac",
+ "hex": "C8A2C8"
+ },
+ {
+ "name": "Wisteria",
+ "hex": "C9A0DC"
+ },
+ {
+ "name": "Heliotrope",
+ "hex": "DF73FF"
+ },
+ {
+ "name": "Byzantium",
+ "hex": "702963"
+ },
+ {
+ "name": "Tyrian",
+ "hex": "66023C"
+ },
+ {
+ "name": "RebeccaPurple",
+ "hex": "663399"
+ },
+ {
+ "name": "Orchid2",
+ "hex": "AF69EF"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/language/control/_section.md b/docs/language/control/_section.md
new file mode 100644
index 00000000..0a4d88a7
--- /dev/null
+++ b/docs/language/control/_section.md
@@ -0,0 +1,7 @@
+---
+id: control
+title: Control flow
+order: 2
+---
+
+Branches, loops, and state machines.
diff --git a/docs/language/control/kw-become.md b/docs/language/control/kw-become.md
new file mode 100644
index 00000000..c837082e
--- /dev/null
+++ b/docs/language/control/kw-become.md
@@ -0,0 +1,12 @@
+---
+id: kw-become
+name: become
+category: control
+kind: keyword
+tokens: become
+sig: become Name
+tip: Transition: to another state of the enclosing machine, or to another scene.
+order: 8
+---
+
+Transition: to another state of the enclosing machine, or to another scene.
diff --git a/docs/language/control/kw-for.md b/docs/language/control/kw-for.md
new file mode 100644
index 00000000..6d3eec2f
--- /dev/null
+++ b/docs/language/control/kw-for.md
@@ -0,0 +1,16 @@
+---
+id: kw-for
+name: for … in
+category: control
+kind: keyword
+tokens: for
+sig: for name in a .. b { … }
+tip: Range loop.
+order: 2
+---
+
+Range loop. Each pass binds a fresh, immutable name — it is not a variable you reuse or reassign; the range a .. b runs from a up to but not including b.
+
+```ludic
+for gy in 0 .. GRID_H { … }
+```
diff --git a/docs/language/control/kw-if.md b/docs/language/control/kw-if.md
new file mode 100644
index 00000000..4957557f
--- /dev/null
+++ b/docs/language/control/kw-if.md
@@ -0,0 +1,12 @@
+---
+id: kw-if
+name: if / else
+category: control
+kind: keyword
+tokens: if else
+sig: if cond { … } else { … }
+tip: A branch.
+order: 0
+---
+
+A branch. Conditions are plain expressions; no parentheses required.
diff --git a/docs/language/control/kw-in.md b/docs/language/control/kw-in.md
new file mode 100644
index 00000000..27e75a76
--- /dev/null
+++ b/docs/language/control/kw-in.md
@@ -0,0 +1,12 @@
+---
+id: kw-in
+name: in
+category: control
+kind: keyword
+tokens: in
+sig: for x in range | query
+tip: Binds the loop name to each value of a range or query.
+order: 3
+---
+
+Binds the loop name to each value of a range or query.
diff --git a/docs/language/control/kw-machine.md b/docs/language/control/kw-machine.md
new file mode 100644
index 00000000..e94773e0
--- /dev/null
+++ b/docs/language/control/kw-machine.md
@@ -0,0 +1,16 @@
+---
+id: kw-machine
+name: machine
+category: control
+kind: keyword
+tokens: machine
+sig: machine store { state Name { … } }
+tip: A state machine over an int var (or register).
+order: 6
+---
+
+A state machine over an int var (or register). It dispatches on the store's value.
+
+```ludic
+machine turn_phase { state KnightMenu { … } }
+```
diff --git a/docs/language/control/kw-match.md b/docs/language/control/kw-match.md
new file mode 100644
index 00000000..6b4f5059
--- /dev/null
+++ b/docs/language/control/kw-match.md
@@ -0,0 +1,12 @@
+---
+id: kw-match
+name: match / when
+category: control
+kind: keyword
+tokens: match
+sig: match x { when a, b => … }
+tip: Multi-way branch on a value, matching one or more literals per arm.
+order: 4
+---
+
+Multi-way branch on a value, matching one or more literals per arm.
diff --git a/docs/language/control/kw-state.md b/docs/language/control/kw-state.md
new file mode 100644
index 00000000..e4c4a91a
--- /dev/null
+++ b/docs/language/control/kw-state.md
@@ -0,0 +1,12 @@
+---
+id: kw-state
+name: state
+category: control
+kind: keyword
+tokens: state
+sig: state Name { … }
+tip: One state of a machine.
+order: 7
+---
+
+One state of a machine.
diff --git a/docs/language/control/kw-when.md b/docs/language/control/kw-when.md
new file mode 100644
index 00000000..976e723c
--- /dev/null
+++ b/docs/language/control/kw-when.md
@@ -0,0 +1,12 @@
+---
+id: kw-when
+name: when
+category: control
+kind: keyword
+tokens: when
+sig: when value => result
+tip: One arm of a match.
+order: 5
+---
+
+One arm of a match.
diff --git a/docs/language/control/kw-while.md b/docs/language/control/kw-while.md
new file mode 100644
index 00000000..8a75d60f
--- /dev/null
+++ b/docs/language/control/kw-while.md
@@ -0,0 +1,12 @@
+---
+id: kw-while
+name: while
+category: control
+kind: keyword
+tokens: while
+sig: while cond { … }
+tip: Loop while the condition holds.
+order: 1
+---
+
+Loop while the condition holds.
diff --git a/docs/language/ecs/_section.md b/docs/language/ecs/_section.md
new file mode 100644
index 00000000..be24fdc7
--- /dev/null
+++ b/docs/language/ecs/_section.md
@@ -0,0 +1,7 @@
+---
+id: ecs
+title: Entities & the ECS
+order: 1
+---
+
+Entities are ids; properties are their data; queries walk them.
diff --git a/docs/language/ecs/fn-world_get.md b/docs/language/ecs/fn-world_get.md
new file mode 100644
index 00000000..9d6c8c7e
--- /dev/null
+++ b/docs/language/ecs/fn-world_get.md
@@ -0,0 +1,12 @@
+---
+id: fn-world_get
+name: world_get / world_set / world_has …
+category: ecs
+kind: builtin
+tokens: world_get world_set world_has world_count world_spawn
+sig: world_get(entity, prop, field) -> int
+tip: The reflection ABI: read and write the world by numeric id, for tools and mods.
+order: 8
+---
+
+The reflection ABI: read and write the world by numeric id, for tools and mods. See also world_count, world_spawn.
diff --git a/docs/language/ecs/kw-attach.md b/docs/language/ecs/kw-attach.md
new file mode 100644
index 00000000..45b708ee
--- /dev/null
+++ b/docs/language/ecs/kw-attach.md
@@ -0,0 +1,12 @@
+---
+id: kw-attach
+name: attach
+category: ecs
+kind: keyword
+tokens: attach
+sig: attach Prop on entity { overrides }
+tip: Add a property to a live entity.
+order: 6
+---
+
+Add a property to a live entity.
diff --git a/docs/language/ecs/kw-despawn.md b/docs/language/ecs/kw-despawn.md
new file mode 100644
index 00000000..16c2df58
--- /dev/null
+++ b/docs/language/ecs/kw-despawn.md
@@ -0,0 +1,12 @@
+---
+id: kw-despawn
+name: despawn
+category: ecs
+kind: keyword
+tokens: despawn
+sig: despawn entity
+tip: Remove an entity.
+order: 1
+---
+
+Remove an entity. Inside a query, despawn self() removes the current match.
diff --git a/docs/language/ecs/kw-detach.md b/docs/language/ecs/kw-detach.md
new file mode 100644
index 00000000..11b7f9e6
--- /dev/null
+++ b/docs/language/ecs/kw-detach.md
@@ -0,0 +1,12 @@
+---
+id: kw-detach
+name: detach
+category: ecs
+kind: keyword
+tokens: detach
+sig: detach Prop on entity
+tip: Remove a property from a live entity.
+order: 7
+---
+
+Remove a property from a live entity.
diff --git a/docs/language/ecs/kw-disable.md b/docs/language/ecs/kw-disable.md
new file mode 100644
index 00000000..26159377
--- /dev/null
+++ b/docs/language/ecs/kw-disable.md
@@ -0,0 +1,12 @@
+---
+id: kw-disable
+name: disable
+category: ecs
+kind: keyword
+tokens: disable
+sig: disable Prop on entity
+tip: Deactivate a property without destroying its data.
+order: 5
+---
+
+Deactivate a property without destroying its data. Also disable Model / a whole handler.
diff --git a/docs/language/ecs/kw-enable.md b/docs/language/ecs/kw-enable.md
new file mode 100644
index 00000000..8200ad53
--- /dev/null
+++ b/docs/language/ecs/kw-enable.md
@@ -0,0 +1,12 @@
+---
+id: kw-enable
+name: enable
+category: ecs
+kind: keyword
+tokens: enable
+sig: enable Prop on entity
+tip: Re-activate a disabled property; its stored values are intact.
+order: 4
+---
+
+Re-activate a disabled property; its stored values are intact.
diff --git a/docs/language/ecs/kw-query.md b/docs/language/ecs/kw-query.md
new file mode 100644
index 00000000..9c4c7db3
--- /dev/null
+++ b/docs/language/ecs/kw-query.md
@@ -0,0 +1,16 @@
+---
+id: kw-query
+name: query
+category: ecs
+kind: keyword
+tokens: query
+sig: query [PropA, PropB, {Tag}]
+tip: Match every entity that carries all listed properties.
+order: 2
+---
+
+Match every entity that carries all listed properties. Use it in for (a, b) in query […]; a {Tag} filters without binding.
+
+```ludic
+for (p, s) in query [Pos, Seg] { … }
+```
diff --git a/docs/language/ecs/kw-self.md b/docs/language/ecs/kw-self.md
new file mode 100644
index 00000000..22d9bff3
--- /dev/null
+++ b/docs/language/ecs/kw-self.md
@@ -0,0 +1,12 @@
+---
+id: kw-self
+name: self
+category: ecs
+kind: keyword
+tokens: self
+sig: self()
+tip: The entity currently bound by the enclosing query.
+order: 3
+---
+
+The entity currently bound by the enclosing query.
diff --git a/docs/language/ecs/kw-spawn.md b/docs/language/ecs/kw-spawn.md
new file mode 100644
index 00000000..b91f2215
--- /dev/null
+++ b/docs/language/ecs/kw-spawn.md
@@ -0,0 +1,16 @@
+---
+id: kw-spawn
+name: spawn
+category: ecs
+kind: keyword
+tokens: spawn
+sig: spawn Label { Prop { field: v }; … }
+tip: Create an entity carrying the listed properties (or a model).
+order: 0
+---
+
+Create an entity carrying the listed properties (or a model). The label is for readability.
+
+```ludic
+spawn Body { Seg { order: 1 }; Pos { x: 9, y: 7 } }
+```
diff --git a/docs/language/events/_section.md b/docs/language/events/_section.md
new file mode 100644
index 00000000..d7754036
--- /dev/null
+++ b/docs/language/events/_section.md
@@ -0,0 +1,7 @@
+---
+id: events
+title: Events
+order: 4
+---
+
+Decoupled, named messages between handlers.
diff --git a/docs/language/events/kw-cancel.md b/docs/language/events/kw-cancel.md
new file mode 100644
index 00000000..f76f04cf
--- /dev/null
+++ b/docs/language/events/kw-cancel.md
@@ -0,0 +1,12 @@
+---
+id: kw-cancel
+name: cancel
+category: events
+kind: keyword
+tokens: cancel
+sig: cancel
+tip: Inside a listener, veto a cancellable event.
+order: 2
+---
+
+Inside a listener, veto a cancellable event.
diff --git a/docs/language/events/kw-emit.md b/docs/language/events/kw-emit.md
new file mode 100644
index 00000000..85c9b26a
--- /dev/null
+++ b/docs/language/events/kw-emit.md
@@ -0,0 +1,12 @@
+---
+id: kw-emit
+name: emit
+category: events
+kind: keyword
+tokens: emit
+sig: emit Name(field: v)
+tip: Fire an event, invoking its listeners.
+order: 1
+---
+
+Fire an event, invoking its listeners. As an expression it yields a cancellable event's cancelled flag.
diff --git a/docs/language/events/kw-event.md b/docs/language/events/kw-event.md
new file mode 100644
index 00000000..b22631f7
--- /dev/null
+++ b/docs/language/events/kw-event.md
@@ -0,0 +1,12 @@
+---
+id: kw-event
+name: event
+category: events
+kind: keyword
+tokens: event
+sig: event Name { field: T = default }
+tip: Declare a public event payload.
+order: 0
+---
+
+Declare a public event payload.
diff --git a/docs/language/input/_section.md b/docs/language/input/_section.md
new file mode 100644
index 00000000..82e97407
--- /dev/null
+++ b/docs/language/input/_section.md
@@ -0,0 +1,7 @@
+---
+id: input
+title: Input
+order: 6
+---
+
+Reading the keyboard.
diff --git a/docs/language/input/input-key.md b/docs/language/input/input-key.md
new file mode 100644
index 00000000..7a3d6e77
--- /dev/null
+++ b/docs/language/input/input-key.md
@@ -0,0 +1,17 @@
+---
+id: input-key
+name: Input.key
+category: input
+kind: namespace-method
+tokens: Input.key
+sig: Input.key() -> int
+tip: The key pressed this frame, as a character code (0 when nothing is pressed).
+order: 0
+---
+
+The key pressed this frame, as a character code (0 when nothing is pressed). Compare against character literals like 'w'.
+
+```ludic
+let k = Input.key()
+if k == 'w' { … }
+```
diff --git a/docs/language/map/_section.md b/docs/language/map/_section.md
new file mode 100644
index 00000000..4a81ff58
--- /dev/null
+++ b/docs/language/map/_section.md
@@ -0,0 +1,7 @@
+---
+id: map
+title: Map — tilemap
+order: 8
+---
+
+A character grid the game paints and reads.
diff --git a/docs/language/map/map-row.md b/docs/language/map/map-row.md
new file mode 100644
index 00000000..a93cc3aa
--- /dev/null
+++ b/docs/language/map/map-row.md
@@ -0,0 +1,16 @@
+---
+id: map-row
+name: Map.row
+category: map
+kind: namespace-method
+tokens: Map.row
+sig: Map.row(y, cells)
+tip: Fill one row of the tilemap from a string of tile characters.
+order: 1
+---
+
+Fill one row of the tilemap from a string of tile characters.
+
+```ludic
+Map.row(y: 0, cells: "####......####")
+```
diff --git a/docs/language/map/map-size.md b/docs/language/map/map-size.md
new file mode 100644
index 00000000..f2487586
--- /dev/null
+++ b/docs/language/map/map-size.md
@@ -0,0 +1,12 @@
+---
+id: map-size
+name: Map.size
+category: map
+kind: namespace-method
+tokens: Map.size
+sig: Map.size(width, height)
+tip: Set the tilemap dimensions in cells.
+order: 0
+---
+
+Set the tilemap dimensions in cells.
diff --git a/docs/language/map/map-tile.md b/docs/language/map/map-tile.md
new file mode 100644
index 00000000..96919691
--- /dev/null
+++ b/docs/language/map/map-tile.md
@@ -0,0 +1,12 @@
+---
+id: map-tile
+name: Map.tile
+category: map
+kind: namespace-method
+tokens: Map.tile
+sig: Map.tile(x, y) -> int
+tip: Read the tile character at a cell.
+order: 2
+---
+
+Read the tile character at a cell. Out-of-bounds reads answer '#', so the edge of the world is a wall for free.
diff --git a/docs/language/operators/_section.md b/docs/language/operators/_section.md
new file mode 100644
index 00000000..971a7c33
--- /dev/null
+++ b/docs/language/operators/_section.md
@@ -0,0 +1,7 @@
+---
+id: operators
+title: Operators & tokens
+order: 11
+---
+
+The symbols the grammar recognizes.
diff --git a/docs/language/operators/op-access.md b/docs/language/operators/op-access.md
new file mode 100644
index 00000000..6d4b4205
--- /dev/null
+++ b/docs/language/operators/op-access.md
@@ -0,0 +1,11 @@
+---
+id: op-access
+name: Member & index
+category: operators
+kind: operator
+sig: x.field buf[i] s[a..b]
+tip: Field/method access, element index, and string slice (a fresh substring).
+order: 6
+---
+
+Field/method access, element index, and string slice (a fresh substring).
diff --git a/docs/language/operators/op-arith.md b/docs/language/operators/op-arith.md
new file mode 100644
index 00000000..e54141c1
--- /dev/null
+++ b/docs/language/operators/op-arith.md
@@ -0,0 +1,11 @@
+---
+id: op-arith
+name: Arithmetic
+category: operators
+kind: operator
+sig: + - * / %
+tip: Add, subtract, multiply, integer-divide, remainder.
+order: 0
+---
+
+Add, subtract, multiply, integer-divide, remainder. On fixed values the same symbols do fixed-point math.
diff --git a/docs/language/operators/op-assign.md b/docs/language/operators/op-assign.md
new file mode 100644
index 00000000..99be2a6d
--- /dev/null
+++ b/docs/language/operators/op-assign.md
@@ -0,0 +1,11 @@
+---
+id: op-assign
+name: Assignment
+category: operators
+kind: operator
+sig: name = value
+tip: Assign to a var, a field, or an element.
+order: 5
+---
+
+Assign to a var, a field, or an element. Not an expression.
diff --git a/docs/language/operators/op-bitwise.md b/docs/language/operators/op-bitwise.md
new file mode 100644
index 00000000..293fe2cb
--- /dev/null
+++ b/docs/language/operators/op-bitwise.md
@@ -0,0 +1,11 @@
+---
+id: op-bitwise
+name: Bitwise
+category: operators
+kind: operator
+sig: & | ^ ~ << >>
+tip: And, or, xor, not, shift left/right.
+order: 3
+---
+
+And, or, xor, not, shift left/right. Shifts and & bind like *; |/^ bind like + — tighter than comparison, so flags & MASK == 0 needs no parentheses.
diff --git a/docs/language/operators/op-comment.md b/docs/language/operators/op-comment.md
new file mode 100644
index 00000000..b3750804
--- /dev/null
+++ b/docs/language/operators/op-comment.md
@@ -0,0 +1,11 @@
+---
+id: op-comment
+name: Comment
+category: operators
+kind: operator
+sig: # to end of line
+tip: Everything after # on a line is a comment.
+order: 9
+---
+
+Everything after # on a line is a comment.
diff --git a/docs/language/operators/op-compare.md b/docs/language/operators/op-compare.md
new file mode 100644
index 00000000..b9da4eda
--- /dev/null
+++ b/docs/language/operators/op-compare.md
@@ -0,0 +1,11 @@
+---
+id: op-compare
+name: Comparison
+category: operators
+kind: operator
+sig: == != < <= > >=
+tip: Yield a bool.
+order: 1
+---
+
+Yield a bool. On strings, == compares contents.
diff --git a/docs/language/operators/op-interp.md b/docs/language/operators/op-interp.md
new file mode 100644
index 00000000..c7180324
--- /dev/null
+++ b/docs/language/operators/op-interp.md
@@ -0,0 +1,15 @@
+---
+id: op-interp
+name: String interpolation
+category: operators
+kind: operator
+sig: `text {expr} more`
+tip: A backtick string with {expr} holes, each stringified and concatenated.
+order: 7
+---
+
+A backtick string with {expr} holes, each stringified and concatenated. {{ and }} are literal braces.
+
+```ludic
+print(`score: {score}`)
+```
diff --git a/docs/language/operators/op-literals.md b/docs/language/operators/op-literals.md
new file mode 100644
index 00000000..cb6bc468
--- /dev/null
+++ b/docs/language/operators/op-literals.md
@@ -0,0 +1,11 @@
+---
+id: op-literals
+name: Literals
+category: operators
+kind: operator
+sig: 42 0x1E90FF 'w' "text" true null
+tip: Decimal and hex ints (hex is how colors are written), a character code in single quotes, a string in double quotes, booleans, and the null pointer.
+order: 8
+---
+
+Decimal and hex ints (hex is how colors are written), a character code in single quotes, a string in double quotes, booleans, and the null pointer.
diff --git a/docs/language/operators/op-logical.md b/docs/language/operators/op-logical.md
new file mode 100644
index 00000000..63e392be
--- /dev/null
+++ b/docs/language/operators/op-logical.md
@@ -0,0 +1,15 @@
+---
+id: op-logical
+name: Logical
+category: operators
+kind: operator
+sig: and or not
+tip: Boolean combinators — words, not symbols.
+order: 2
+---
+
+Boolean combinators — words, not symbols.
+
+```ludic
+if k != 0 and mode == 0 { … }
+```
diff --git a/docs/language/operators/op-range.md b/docs/language/operators/op-range.md
new file mode 100644
index 00000000..9b55b1df
--- /dev/null
+++ b/docs/language/operators/op-range.md
@@ -0,0 +1,11 @@
+---
+id: op-range
+name: Range
+category: operators
+kind: operator
+sig: a .. b
+tip: A half-open range for for loops: a up to but not including b.
+order: 4
+---
+
+A half-open range for for loops: a up to but not including b.
diff --git a/docs/language/random/_section.md b/docs/language/random/_section.md
new file mode 100644
index 00000000..03258f7e
--- /dev/null
+++ b/docs/language/random/_section.md
@@ -0,0 +1,7 @@
+---
+id: random
+title: Random
+order: 7
+---
+
+A seeded, deterministic RNG — same seed, same sequence, every run and every platform.
diff --git a/docs/language/random/random-chance.md b/docs/language/random/random-chance.md
new file mode 100644
index 00000000..391935ae
--- /dev/null
+++ b/docs/language/random/random-chance.md
@@ -0,0 +1,12 @@
+---
+id: random-chance
+name: Random.chance
+category: random
+kind: namespace-method
+tokens: Random.chance
+sig: Random.chance(percent) -> bool
+tip: True with the given percent probability.
+order: 1
+---
+
+True with the given percent probability.
diff --git a/docs/language/random/random-range.md b/docs/language/random/random-range.md
new file mode 100644
index 00000000..4015e332
--- /dev/null
+++ b/docs/language/random/random-range.md
@@ -0,0 +1,16 @@
+---
+id: random-range
+name: Random.range
+category: random
+kind: namespace-method
+tokens: Random.range
+sig: Random.range(low, high) -> int
+tip: A random integer in the inclusive range [low, high].
+order: 0
+---
+
+A random integer in the inclusive range [low, high].
+
+```ludic
+food_x = Random.range(low: 0, high: GRID_W - 1)
+```
diff --git a/docs/language/random/random-seed.md b/docs/language/random/random-seed.md
new file mode 100644
index 00000000..ddf87af1
--- /dev/null
+++ b/docs/language/random/random-seed.md
@@ -0,0 +1,12 @@
+---
+id: random-seed
+name: Random.seed
+category: random
+kind: namespace-method
+tokens: Random.seed
+sig: Random.seed(value)
+tip: Seed the RNG.
+order: 2
+---
+
+Seed the RNG. Seeding with the same value makes runs reproducible.
diff --git a/docs/language/scenes/_section.md b/docs/language/scenes/_section.md
new file mode 100644
index 00000000..2346e963
--- /dev/null
+++ b/docs/language/scenes/_section.md
@@ -0,0 +1,7 @@
+---
+id: scenes
+title: Scenes & layers
+order: 3
+---
+
+One active scene at a time, each grouping handlers into layers.
diff --git a/docs/language/scenes/kw-enter.md b/docs/language/scenes/kw-enter.md
new file mode 100644
index 00000000..f7abdcb1
--- /dev/null
+++ b/docs/language/scenes/kw-enter.md
@@ -0,0 +1,12 @@
+---
+id: kw-enter
+name: enter
+category: scenes
+kind: keyword
+tokens: enter
+sig: on enter { … }
+tip: The scene-entry hook.
+order: 3
+---
+
+The scene-entry hook.
diff --git a/docs/language/scenes/kw-exit.md b/docs/language/scenes/kw-exit.md
new file mode 100644
index 00000000..ef41b48f
--- /dev/null
+++ b/docs/language/scenes/kw-exit.md
@@ -0,0 +1,12 @@
+---
+id: kw-exit
+name: exit
+category: scenes
+kind: keyword
+tokens: exit
+sig: on exit { … }
+tip: The scene-exit hook.
+order: 4
+---
+
+The scene-exit hook.
diff --git a/docs/language/scenes/kw-layer.md b/docs/language/scenes/kw-layer.md
new file mode 100644
index 00000000..b4ec299f
--- /dev/null
+++ b/docs/language/scenes/kw-layer.md
@@ -0,0 +1,12 @@
+---
+id: kw-layer
+name: layer
+category: scenes
+kind: keyword
+tokens: layer
+sig: layer Name { handlers }
+tip: A group of handlers inside a scene; layers render in declaration order.
+order: 1
+---
+
+A group of handlers inside a scene; layers render in declaration order.
diff --git a/docs/language/scenes/kw-on.md b/docs/language/scenes/kw-on.md
new file mode 100644
index 00000000..5853ed20
--- /dev/null
+++ b/docs/language/scenes/kw-on.md
@@ -0,0 +1,12 @@
+---
+id: kw-on
+name: on enter / on exit
+category: scenes
+kind: keyword
+tokens: on
+sig: on enter { … } on exit { … }
+tip: Hooks that fire when a scene becomes active or is left.
+order: 2
+---
+
+Hooks that fire when a scene becomes active or is left.
diff --git a/docs/language/scenes/kw-scene.md b/docs/language/scenes/kw-scene.md
new file mode 100644
index 00000000..ee9a6106
--- /dev/null
+++ b/docs/language/scenes/kw-scene.md
@@ -0,0 +1,16 @@
+---
+id: kw-scene
+name: scene
+category: scenes
+kind: keyword
+tokens: scene
+sig: scene Name [start] { on enter{} on exit{} layer … }
+tip: A mutually-exclusive game state.
+order: 0
+---
+
+A mutually-exclusive game state. Mark one start.
+
+```ludic
+scene Title start { on enter { … } layer Main { … } }
+```
diff --git a/docs/language/screen/_section.md b/docs/language/screen/_section.md
new file mode 100644
index 00000000..8c8dd620
--- /dev/null
+++ b/docs/language/screen/_section.md
@@ -0,0 +1,7 @@
+---
+id: screen
+title: Screen — drawing
+order: 5
+---
+
+The 2D drawing surface. Every call takes named arguments; draw during the Render phase, then Screen.show().
diff --git a/docs/language/screen/screen-clear.md b/docs/language/screen/screen-clear.md
new file mode 100644
index 00000000..99ec0ae2
--- /dev/null
+++ b/docs/language/screen/screen-clear.md
@@ -0,0 +1,16 @@
+---
+id: screen-clear
+name: Screen.clear
+category: screen
+kind: namespace-method
+tokens: Screen.clear
+sig: Screen.clear(color)
+tip: Fill the entire screen with one color.
+order: 0
+---
+
+Fill the entire screen with one color.
+
+```ludic
+Screen.clear(Color.MidnightBlue)
+```
diff --git a/docs/language/screen/screen-draw_number.md b/docs/language/screen/screen-draw_number.md
new file mode 100644
index 00000000..5ab84bcc
--- /dev/null
+++ b/docs/language/screen/screen-draw_number.md
@@ -0,0 +1,16 @@
+---
+id: screen-draw_number
+name: Screen.draw_number
+category: screen
+kind: namespace-method
+tokens: Screen.draw_number
+sig: Screen.draw_number(x, y, value, color, scale)
+tip: Draw an integer — no allocation, no string conversion.
+order: 5
+---
+
+Draw an integer — no allocation, no string conversion.
+
+```ludic
+Screen.draw_number(x: 52, y: 4, value: score, color: Color.Gold, scale: 1)
+```
diff --git a/docs/language/screen/screen-draw_rectangle.md b/docs/language/screen/screen-draw_rectangle.md
new file mode 100644
index 00000000..eb1f9b0f
--- /dev/null
+++ b/docs/language/screen/screen-draw_rectangle.md
@@ -0,0 +1,12 @@
+---
+id: screen-draw_rectangle
+name: Screen.draw_rectangle
+category: screen
+kind: namespace-method
+tokens: Screen.draw_rectangle
+sig: Screen.draw_rectangle(x, y, width, height, color)
+tip: Draw a one-pixel rectangle outline.
+order: 2
+---
+
+Draw a one-pixel rectangle outline.
diff --git a/docs/language/screen/screen-draw_text.md b/docs/language/screen/screen-draw_text.md
new file mode 100644
index 00000000..39a32223
--- /dev/null
+++ b/docs/language/screen/screen-draw_text.md
@@ -0,0 +1,16 @@
+---
+id: screen-draw_text
+name: Screen.draw_text
+category: screen
+kind: namespace-method
+tokens: Screen.draw_text
+sig: Screen.draw_text(x, y, text, color, scale)
+tip: Draw a string with the built-in font at an integer scale.
+order: 4
+---
+
+Draw a string with the built-in font at an integer scale.
+
+```ludic
+Screen.draw_text(x: 6, y: 4, text: "SCORE", color: Color.White, scale: 1)
+```
diff --git a/docs/language/screen/screen-fill_rectangle.md b/docs/language/screen/screen-fill_rectangle.md
new file mode 100644
index 00000000..e7fd2423
--- /dev/null
+++ b/docs/language/screen/screen-fill_rectangle.md
@@ -0,0 +1,16 @@
+---
+id: screen-fill_rectangle
+name: Screen.fill_rectangle
+category: screen
+kind: namespace-method
+tokens: Screen.fill_rectangle
+sig: Screen.fill_rectangle(x, y, width, height, color)
+tip: Draw a filled rectangle.
+order: 1
+---
+
+Draw a filled rectangle.
+
+```ludic
+Screen.fill_rectangle(x: 8, y: 8, width: 16, height: 16, color: Color.Crimson)
+```
diff --git a/docs/language/screen/screen-height.md b/docs/language/screen/screen-height.md
new file mode 100644
index 00000000..f795b9fe
--- /dev/null
+++ b/docs/language/screen/screen-height.md
@@ -0,0 +1,12 @@
+---
+id: screen-height
+name: Screen.height
+category: screen
+kind: namespace-method
+tokens: Screen.height
+sig: Screen.height() -> int
+tip: Framebuffer height in pixels.
+order: 8
+---
+
+Framebuffer height in pixels.
diff --git a/docs/language/screen/screen-put_pixel.md b/docs/language/screen/screen-put_pixel.md
new file mode 100644
index 00000000..4aca1265
--- /dev/null
+++ b/docs/language/screen/screen-put_pixel.md
@@ -0,0 +1,12 @@
+---
+id: screen-put_pixel
+name: Screen.put_pixel
+category: screen
+kind: namespace-method
+tokens: Screen.put_pixel
+sig: Screen.put_pixel(x, y, color)
+tip: Set a single pixel.
+order: 3
+---
+
+Set a single pixel.
diff --git a/docs/language/screen/screen-show.md b/docs/language/screen/screen-show.md
new file mode 100644
index 00000000..3f2c7be1
--- /dev/null
+++ b/docs/language/screen/screen-show.md
@@ -0,0 +1,16 @@
+---
+id: screen-show
+name: Screen.show
+category: screen
+kind: namespace-method
+tokens: Screen.show
+sig: Screen.show()
+tip: Present the finished frame — copy everything you have drawn to the window.
+order: 6
+---
+
+Present the finished frame — copy everything you have drawn to the window. Call it once, last, in your Render handler.
+
+```ludic
+Screen.show()
+```
diff --git a/docs/language/screen/screen-status.md b/docs/language/screen/screen-status.md
new file mode 100644
index 00000000..b4da7f82
--- /dev/null
+++ b/docs/language/screen/screen-status.md
@@ -0,0 +1,12 @@
+---
+id: screen-status
+name: Screen.status
+category: screen
+kind: namespace-method
+tokens: Screen.status
+sig: Screen.status(text)
+tip: Set the persistent one-line status/HUD string.
+order: 9
+---
+
+Set the persistent one-line status/HUD string.
diff --git a/docs/language/screen/screen-width.md b/docs/language/screen/screen-width.md
new file mode 100644
index 00000000..7d3725a5
--- /dev/null
+++ b/docs/language/screen/screen-width.md
@@ -0,0 +1,12 @@
+---
+id: screen-width
+name: Screen.width
+category: screen
+kind: namespace-method
+tokens: Screen.width
+sig: Screen.width() -> int
+tip: Framebuffer width in pixels.
+order: 7
+---
+
+Framebuffer width in pixels.
diff --git a/docs/language/structure/_section.md b/docs/language/structure/_section.md
new file mode 100644
index 00000000..ea5e2a90
--- /dev/null
+++ b/docs/language/structure/_section.md
@@ -0,0 +1,7 @@
+---
+id: structure
+title: Program structure
+order: 0
+---
+
+The shape of a Ludic program: one program block holding declarations.
diff --git a/docs/language/structure/kw-const.md b/docs/language/structure/kw-const.md
new file mode 100644
index 00000000..c0476c4e
--- /dev/null
+++ b/docs/language/structure/kw-const.md
@@ -0,0 +1,12 @@
+---
+id: kw-const
+name: const
+category: structure
+kind: keyword
+tokens: const
+sig: const NAME: T = value
+tip: A compile-time constant.
+order: 5
+---
+
+A compile-time constant. Folds directly into the code — no storage, no cost.
diff --git a/docs/language/structure/kw-extern.md b/docs/language/structure/kw-extern.md
new file mode 100644
index 00000000..e3250e86
--- /dev/null
+++ b/docs/language/structure/kw-extern.md
@@ -0,0 +1,12 @@
+---
+id: kw-extern
+name: extern
+category: structure
+kind: keyword
+tokens: extern
+sig: extern fn name(a: T) -> R = "symbol"
+tip: Bind a name to an external C-ABI symbol — the seam for platform and library calls.
+order: 12
+---
+
+Bind a name to an external C-ABI symbol — the seam for platform and library calls.
diff --git a/docs/language/structure/kw-fn.md b/docs/language/structure/kw-fn.md
new file mode 100644
index 00000000..296a7d40
--- /dev/null
+++ b/docs/language/structure/kw-fn.md
@@ -0,0 +1,12 @@
+---
+id: kw-fn
+name: fn
+category: structure
+kind: keyword
+tokens: fn
+sig: fn name(a: T, b: T) -> R { … }
+tip: A function.
+order: 8
+---
+
+A function. Call it positionally or with named arguments: name(a: 1, b: 2).
diff --git a/docs/language/structure/kw-handler.md b/docs/language/structure/kw-handler.md
new file mode 100644
index 00000000..ca8143d5
--- /dev/null
+++ b/docs/language/structure/kw-handler.md
@@ -0,0 +1,16 @@
+---
+id: kw-handler
+name: handler
+category: structure
+kind: keyword
+tokens: handler
+sig: handler Name phase P { … }
+tip: A block of code the engine runs every frame during phase P.
+order: 3
+---
+
+A block of code the engine runs every frame during phase P. With a query, the body runs once per matching entity.
+
+```ludic
+handler Move phase Update { … }
+```
diff --git a/docs/language/structure/kw-import.md b/docs/language/structure/kw-import.md
new file mode 100644
index 00000000..308f1641
--- /dev/null
+++ b/docs/language/structure/kw-import.md
@@ -0,0 +1,12 @@
+---
+id: kw-import
+name: import
+category: structure
+kind: keyword
+tokens: import
+sig: import "file.ludic"
+tip: Splice another Ludic file into this program.
+order: 10
+---
+
+Splice another Ludic file into this program. Paths resolve relative to the importer; re-imports are free.
diff --git a/docs/language/structure/kw-let.md b/docs/language/structure/kw-let.md
new file mode 100644
index 00000000..1d0ea24c
--- /dev/null
+++ b/docs/language/structure/kw-let.md
@@ -0,0 +1,12 @@
+---
+id: kw-let
+name: let
+category: structure
+kind: keyword
+tokens: let
+sig: let name = value
+tip: An immutable binding, scoped to the block it appears in.
+order: 7
+---
+
+An immutable binding, scoped to the block it appears in.
diff --git a/docs/language/structure/kw-model.md b/docs/language/structure/kw-model.md
new file mode 100644
index 00000000..8f079e70
--- /dev/null
+++ b/docs/language/structure/kw-model.md
@@ -0,0 +1,16 @@
+---
+id: kw-model
+name: model
+category: structure
+kind: keyword
+tokens: model
+sig: model Name { PropA, PropB, … }
+tip: A named bundle of properties, so an entity that always travels together is spawned by one name.
+order: 2
+---
+
+A named bundle of properties, so an entity that always travels together is spawned by one name.
+
+```ludic
+model Player { Health, Shield }
+```
diff --git a/docs/language/structure/kw-module.md b/docs/language/structure/kw-module.md
new file mode 100644
index 00000000..4aec9649
--- /dev/null
+++ b/docs/language/structure/kw-module.md
@@ -0,0 +1,12 @@
+---
+id: kw-module
+name: module
+category: structure
+kind: keyword
+tokens: module
+sig: module Name { @export fn … }
+tip: Build a shared library of plain C-ABI symbols instead of an executable.
+order: 11
+---
+
+Build a shared library of plain C-ABI symbols instead of an executable.
diff --git a/docs/language/structure/kw-phase.md b/docs/language/structure/kw-phase.md
new file mode 100644
index 00000000..99f1ab8b
--- /dev/null
+++ b/docs/language/structure/kw-phase.md
@@ -0,0 +1,12 @@
+---
+id: kw-phase
+name: phase
+category: structure
+kind: phase
+tokens: Start Input Update FixedUpdate Render
+sig: phase Start | Input | Update | FixedUpdate | Render
+tip: When a handler runs.
+order: 4
+---
+
+When a handler runs. Start once at boot; Input reads the keyboard; Update is the per-frame step; FixedUpdate is the deterministic fixed-step; Render draws the frame.
diff --git a/docs/language/structure/kw-program.md b/docs/language/structure/kw-program.md
new file mode 100644
index 00000000..ab575b19
--- /dev/null
+++ b/docs/language/structure/kw-program.md
@@ -0,0 +1,12 @@
+---
+id: kw-program
+name: program
+category: structure
+kind: keyword
+tokens: program
+sig: program Name { … }
+tip: The top-level unit.
+order: 0
+---
+
+The top-level unit. A program compiles to one native game; everything else lives inside it.
diff --git a/docs/language/structure/kw-property.md b/docs/language/structure/kw-property.md
new file mode 100644
index 00000000..6c86f35b
--- /dev/null
+++ b/docs/language/structure/kw-property.md
@@ -0,0 +1,16 @@
+---
+id: kw-property
+name: property
+category: structure
+kind: keyword
+tokens: property
+sig: property Name { field: T = default, … }
+tip: A component: a named record of fields an entity can carry.
+order: 1
+---
+
+A component: a named record of fields an entity can carry. Fields have a type and a default.
+
+```ludic
+property Pos { x: int = 0, y: int = 0 }
+```
diff --git a/docs/language/structure/kw-return.md b/docs/language/structure/kw-return.md
new file mode 100644
index 00000000..15755e6f
--- /dev/null
+++ b/docs/language/structure/kw-return.md
@@ -0,0 +1,12 @@
+---
+id: kw-return
+name: return
+category: structure
+kind: keyword
+tokens: return
+sig: return value
+tip: Return from a function.
+order: 9
+---
+
+Return from a function.
diff --git a/docs/language/structure/kw-var.md b/docs/language/structure/kw-var.md
new file mode 100644
index 00000000..8481a059
--- /dev/null
+++ b/docs/language/structure/kw-var.md
@@ -0,0 +1,16 @@
+---
+id: kw-var
+name: var
+category: structure
+kind: keyword
+tokens: var
+sig: var name: T = value
+tip: A mutable binding.
+order: 6
+---
+
+A mutable binding. At program scope it is your game's persistent, named state — the modern replacement for numeric registers.
+
+```ludic
+var score: int = 0
+```
diff --git a/docs/language/types/_section.md b/docs/language/types/_section.md
new file mode 100644
index 00000000..05af7757
--- /dev/null
+++ b/docs/language/types/_section.md
@@ -0,0 +1,7 @@
+---
+id: types
+title: Types
+order: 10
+---
+
+Ludic is statically typed; most code uses just int.
diff --git a/docs/language/types/type-bool.md b/docs/language/types/type-bool.md
new file mode 100644
index 00000000..742ed671
--- /dev/null
+++ b/docs/language/types/type-bool.md
@@ -0,0 +1,12 @@
+---
+id: type-bool
+name: bool
+category: types
+kind: type
+tokens: bool
+sig: bool
+tip: A truth value: the result of comparisons and and/or/not.
+order: 2
+---
+
+A truth value: the result of comparisons and and/or/not.
diff --git a/docs/language/types/type-byte.md b/docs/language/types/type-byte.md
new file mode 100644
index 00000000..7b41a0a2
--- /dev/null
+++ b/docs/language/types/type-byte.md
@@ -0,0 +1,12 @@
+---
+id: type-byte
+name: byte
+category: types
+kind: type
+tokens: byte
+sig: byte
+tip: A single byte, as read from a ptr index.
+order: 7
+---
+
+A single byte, as read from a ptr index.
diff --git a/docs/language/types/type-entity.md b/docs/language/types/type-entity.md
new file mode 100644
index 00000000..29a79ce6
--- /dev/null
+++ b/docs/language/types/type-entity.md
@@ -0,0 +1,12 @@
+---
+id: type-entity
+name: entity
+category: types
+kind: type
+tokens: entity
+sig: entity
+tip: An entity id, as returned by self().
+order: 4
+---
+
+An entity id, as returned by self().
diff --git a/docs/language/types/type-fixed.md b/docs/language/types/type-fixed.md
new file mode 100644
index 00000000..f386c28a
--- /dev/null
+++ b/docs/language/types/type-fixed.md
@@ -0,0 +1,12 @@
+---
+id: type-fixed
+name: fixed
+category: types
+kind: type
+tokens: fixed
+sig: fixed
+tip: Q16.16 fixed-point — deterministic fractional math.
+order: 1
+---
+
+Q16.16 fixed-point — deterministic fractional math. fx(n) lifts an int in; flr(x) takes the floor back out.
diff --git a/docs/language/types/type-int.md b/docs/language/types/type-int.md
new file mode 100644
index 00000000..40d6d6b8
--- /dev/null
+++ b/docs/language/types/type-int.md
@@ -0,0 +1,12 @@
+---
+id: type-int
+name: int
+category: types
+kind: type
+tokens: int
+sig: int
+tip: A 32-bit signed integer — the default numeric type, and how colors, keys and tiles are carried.
+order: 0
+---
+
+A 32-bit signed integer — the default numeric type, and how colors, keys and tiles are carried.
diff --git a/docs/language/types/type-ptr.md b/docs/language/types/type-ptr.md
new file mode 100644
index 00000000..f3ce20f0
--- /dev/null
+++ b/docs/language/types/type-ptr.md
@@ -0,0 +1,12 @@
+---
+id: type-ptr
+name: ptr
+category: types
+kind: type
+tokens: ptr
+sig: ptr
+tip: A raw byte buffer (see bytes(n)).
+order: 5
+---
+
+A raw byte buffer (see bytes(n)).
diff --git a/docs/language/types/type-slices.md b/docs/language/types/type-slices.md
new file mode 100644
index 00000000..3056b260
--- /dev/null
+++ b/docs/language/types/type-slices.md
@@ -0,0 +1,11 @@
+---
+id: type-slices
+name: []T (slices)
+category: types
+kind: type
+sig: []T
+tip: A growable slice of T.
+order: 8
+---
+
+A growable slice of T. new []T makes one; push(s, x) appends; len(s) counts; s[i] indexes.
diff --git a/docs/language/types/type-str.md b/docs/language/types/type-str.md
new file mode 100644
index 00000000..ce9dc9a3
--- /dev/null
+++ b/docs/language/types/type-str.md
@@ -0,0 +1,12 @@
+---
+id: type-str
+name: str
+category: types
+kind: type
+tokens: str
+sig: str
+tip: An immutable string.
+order: 3
+---
+
+An immutable string. Index for a character code; slice with s[a..b]; interpolate with `text {expr}`.
diff --git a/docs/language/types/type-words.md b/docs/language/types/type-words.md
new file mode 100644
index 00000000..a0d1fa9a
--- /dev/null
+++ b/docs/language/types/type-words.md
@@ -0,0 +1,12 @@
+---
+id: type-words
+name: words
+category: types
+kind: type
+tokens: words
+sig: words
+tip: A raw buffer of 32-bit words (see words(n)).
+order: 6
+---
+
+A raw buffer of 32-bit words (see words(n)).
diff --git a/docs/site/site.json b/docs/site/site.json
new file mode 100644
index 00000000..a358652a
--- /dev/null
+++ b/docs/site/site.json
@@ -0,0 +1,104 @@
+{
+ "brand": "Ludic",
+ "tagline": "The opinionated, compiled language for 2D games.",
+ "repo_url": "https://git.workshopsoft.io/workshopsoft/ludic",
+ "meta": {
+ "title": "Ludic — the opinionated game language",
+ "description": "Ludic is an opinionated, compiled language for 2D games. The entity system, rendering, named colors, input, deterministic math and save/load are part of the language — so you build a game, not a framework.",
+ "og_title": "Ludic — the opinionated game language",
+ "og_description": "An opinionated, compiled language for 2D games. The ECS, rendering, colors, input and save/load are built in — build a game, not a framework."
+ },
+ "nav_links": [
+ { "label": "Features", "href": "#features" },
+ { "label": "Examples", "href": "#showcase" },
+ { "label": "Get started", "href": "#start" },
+ { "label": "API Reference", "href": "api.html" }
+ ],
+ "hero": {
+ "pill": "Opinionated · Batteries-included · No framework",
+ "title_pre": "Build a game,",
+ "title_accent": "not a framework.",
+ "lead": "Ludic is an opinionated, compiled language for 2D games. The entity system, drawing, named colors, input, deterministic math and save/load aren't libraries you wire up — they're part of the language. You write the game; there's nothing to assemble first.",
+ "snippet": "docs/site/snippets/hero.ludic",
+ "snippet_name": "hello.ludic",
+ "primary_cta": { "label": "Get started →", "href": "#start" },
+ "secondary_cta": { "label": "API Reference", "href": "api.html" },
+ "pipeline": [".ludic", "ludicc", "optimized IR", "native binary"],
+ "pipeline_note": "One step from your code to a native binary. No engine to install, no interpreter, no runtime to ship alongside it — the game is the executable."
+ },
+ "features": {
+ "kicker": "Why Ludic",
+ "title": "A language shaped around the game.",
+ "intro": "The things you normally bolt on — an entity-component system, a renderer, a deterministic clock, save/load — are primitives of the language itself. One opinionated way to do each, so there's little to decide and nothing to wire up.",
+ "cards": [
+ { "icon": "🧩", "title": "ECS in the syntax", "html": "property, model, and handler are keywords. Query entities with query [A, B, {Tag}] and iterate matches directly — no framework to wire up." },
+ { "icon": "⏱️", "title": "Deterministic runtime", "html": "Q16.16 fixed-point math and a seeded RNG mean the same inputs produce the same frame — byte-for-byte — every run. Ideal for replays and lockstep netcode." },
+ { "icon": "🎨", "title": "Drawing & named colors", "html": "The Screen API draws rectangles, text and pixels with named arguments; Color.Crimson and 220 more names read like English and cost nothing at runtime." },
+ { "icon": "📦", "title": "One self-contained binary", "html": "A game compiles to a single native executable — no engine to install, no interpreter, no runtime shipped beside it. Build, and run the file." },
+ { "icon": "💾", "title": "Snapshot save/load", "html": "save() and load() serialize the entire ECS world — every entity, property and program var — in one call." },
+ { "icon": "🎬", "title": "Scenes & state machines", "html": "scene/layer/become model mutually-exclusive game states with enter/exit hooks; match/machine/state handle dispatch and per-entity FSMs." },
+ { "icon": "🖼️", "title": "Retained UI & assets", "html": "Declare a widget tree as data with ui — panels, labels, buttons, 9-slice skins, keyboard focus. Sprites decode from PNG at runtime; text is real TrueType." },
+ { "icon": "🔌", "title": "Modules & native calls", "html": "module + @export fn builds a shared library of plain native symbols; extern fn … = \"symbol\" reaches out to any native library when you need the platform." }
+ ]
+ },
+ "showcase": {
+ "kicker": "Show, don't tell",
+ "title": "Real programs, one toolchain.",
+ "intro": "The same ludicc that builds a JRPG builds a from-scratch Snake and a scene demo. Nothing is hardcoded to a genre — and every token below links into the reference.",
+ "samples": [
+ { "label": "snake", "name": "snake.ludic", "file": "docs/site/snippets/snake.ludic",
+ "note": "A complete Snake — grid, growth, food, game-over — from primitives. State is named vars, colors are named, and every draw call says what each argument is." },
+ { "label": "scenes", "name": "scenes.ludic", "file": "docs/site/snippets/scenes.ludic",
+ "note": "One active scene at a time. `become` runs the old scene's on-exit and the new one's on-enter; layers draw in declaration order. State is a plain named var." },
+ { "label": "lifecycle", "name": "toggle.ludic", "file": "docs/site/snippets/lifecycle.ludic",
+ "note": "Enable/disable at three scopes — entity, model, handler. Disabling never destroys data: a property's values persist, so a later enable restores them." },
+ { "label": "hello", "name": "hello.ludic", "file": "docs/site/snippets/hero.ludic",
+ "note": "The smallest program that exercises the whole pipeline: properties, a spawn, a queried handler, and a render/quit." }
+ ]
+ },
+ "philosophy": {
+ "kicker": "The philosophy",
+ "title": "No framework. No glue. Just the game.",
+ "paras": [
+ "Most game code is plumbing — registering systems, wiring a renderer, threading state through a framework. Ludic makes those decisions for you and bakes them into the language, so the code you write is the game's actual logic.",
+ "Opinionated on purpose: one clear way to spawn an entity, draw a frame, name a color, run a scene. Less to choose, less to learn, less to maintain."
+ ],
+ "stats": [
+ { "big": "0", "lbl": "engines to install" },
+ { "big": "1", "lbl": "clear way to do each thing" },
+ { "big": "=", "lbl": "deterministic frames" }
+ ]
+ },
+ "start": {
+ "kicker": "Get started",
+ "title": "From clone to a native window.",
+ "intro": "Build the toolchain once, then your game. Everything runs from the repo root.",
+ "steps": [
+ { "title": "Build the toolchain", "html": "bin/x build produces ludicc, the compiler you'll use for everything below." },
+ { "title": "Compile & run an example", "html": "bin/x app examples/snake.ludic turns a .ludic file into a native binary. Run it to open a real window." },
+ { "title": "Go headless for tests", "html": "--headless renders frames to a .ppm from piped input — deterministic output you can diff." },
+ { "title": "Edit with full tooling", "html": "bin/x tools builds the formatter and language server; every editor gets completion, diagnostics and go-to-definition." }
+ ],
+ "terminal_name": "zsh — ludic",
+ "terminal": [
+ { "comment": "build the toolchain (once)" },
+ { "cmd": "bin/x build" },
+ { "blank": true },
+ { "comment": "build and run an example (opens a window)" },
+ { "cmd": "bin/x app examples/snake.ludic" },
+ { "cmd": "./build/snake" },
+ { "blank": true },
+ { "comment": "deterministic headless render for tests" },
+ { "cmd": "bin/x app examples/snake.ludic --headless" },
+ { "cmd": "printf 'ddddwww' | ./build/snake_headless" },
+ { "out": "→ writes out.ppm" }
+ ]
+ },
+ "editors": {
+ "kicker": "Editor experience",
+ "title": "One language server, every editor.",
+ "intro": "ludic-lsp speaks LSP 3.17 over stdio: context-aware completion, diagnostics from the compiler itself, go-to-definition and rename across imported files, and comment-preserving formatting. It even understands ```ludic fences in Markdown.",
+ "list": ["VS Code", "JetBrains IDEs", "Neovim", "Helix", "Emacs", "Sublime Text", "Zed"],
+ "note": "bin/x tools builds ludic-fmt and ludic-lsp — the same formatter runs as a CLI for pre-commit hooks and CI."
+ }
+}
diff --git a/docs/site/snippets/hero.ludic b/docs/site/snippets/hero.ludic
new file mode 100644
index 00000000..fb147eb5
--- /dev/null
+++ b/docs/site/snippets/hero.ludic
@@ -0,0 +1,24 @@
+# the smallest program that exercises the whole ECS pipeline
+program Hello {
+
+ property Pos { x: int = 0, y: int = 0 }
+ property Vel { dx: int = 0, dy: int = 0 }
+
+ handler Boot phase Start {
+ spawn Mob { Pos { x: 3, y: 4 } Vel { dx: 1, dy: 0 } }
+ spawn Mob { Pos { x: 10, y: 2 } Vel { dx: 0, dy: 1 } }
+ }
+
+ # a handler declares the entities it touches; the body
+ # runs once per match, each property bound by name.
+ @Queries(these: [Pos, Vel])
+ handler Move phase FixedUpdate {
+ Pos.x = Pos.x + Vel.dx
+ Pos.y = Pos.y + Vel.dy
+ }
+
+ handler Report phase Update {
+ for (p) in query [Pos] { print(p.x) print(p.y) }
+ quit()
+ }
+}
diff --git a/docs/site/snippets/lifecycle.ludic b/docs/site/snippets/lifecycle.ludic
new file mode 100644
index 00000000..6c4371d5
--- /dev/null
+++ b/docs/site/snippets/lifecycle.ludic
@@ -0,0 +1,20 @@
+program Toggles {
+ property Health { hp: int = 0, max: int = 100 }
+ property Shield { amount: int = 0 }
+ model Player { Health, Shield }
+
+ # hooks fire at the toggle point, data bound by name
+ @OnDisable(Shield) handler Down { print(Shield.amount + 1) }
+ @OnEnable(Shield) handler Up { print(Shield.amount + 2) }
+
+ handler Seed phase Start {
+ spawn Player { Health { max: 50 }, Shield { amount: 5 } }
+ }
+
+ handler Run phase Render {
+ for (e) in query [Player] { disable Shield on self() } # @OnDisable
+ for (e) in query [Player] { enable Shield on self() } # @OnEnable, data intact
+ disable Player # whole model off
+ quit()
+ }
+}
diff --git a/docs/site/snippets/scenes.ludic b/docs/site/snippets/scenes.ludic
new file mode 100644
index 00000000..4ebb7659
--- /dev/null
+++ b/docs/site/snippets/scenes.ludic
@@ -0,0 +1,30 @@
+program SceneDemo {
+ var counter: int = 0
+
+ handler Boot phase Start { counter = 0 print(1000) }
+
+ scene Title start {
+ on enter { print(1) }
+ on exit { print(2) }
+ layer Main {
+ handler Tick phase Update {
+ counter = counter + 1
+ print(100 + counter)
+ if counter >= 2 { become Play } # hand off to Play
+ }
+ }
+ }
+
+ scene Play {
+ on enter { print(3) counter = 0 }
+ on exit { print(4) }
+ layer World {
+ handler Step phase Update {
+ counter = counter + 1
+ print(200 + counter)
+ if counter >= 2 { quit() }
+ }
+ }
+ layer Hud { handler Draw phase Render { print(900) } }
+ }
+}
diff --git a/docs/site/snippets/snake.ludic b/docs/site/snippets/snake.ludic
new file mode 100644
index 00000000..0223ae0e
--- /dev/null
+++ b/docs/site/snippets/snake.ludic
@@ -0,0 +1,30 @@
+program Snake {
+ property Pos { x: int = 0, y: int = 0 }
+ property Seg { order: int = 0 }
+
+ const GRID_W: int = 20 const GRID_H: int = 15 const TILE: int = 16
+
+ var food_x: int = 14 var food_y: int = 7 var score: int = 0
+
+ handler Draw phase Render {
+ Screen.clear(Color.MidnightBlue)
+ # checkerboard field — a fresh, immutable gx/gy each pass
+ for gy in 0 .. GRID_H {
+ for gx in 0 .. GRID_W {
+ var tile_color = Color.Charcoal
+ if (gx + gy) % 2 == 0 { tile_color = Color.Gunmetal }
+ Screen.fill_rectangle(x: gx * TILE, y: gy * TILE, width: TILE, height: TILE, color: tile_color)
+ }
+ }
+ # the food, then every snake segment straight from the ECS
+ Screen.fill_rectangle(x: food_x * TILE + 3, y: food_y * TILE + 3, width: TILE - 6, height: TILE - 6, color: Color.Crimson)
+ for (p, s) in query [Pos, Seg] {
+ var seg_color = Color.LimeGreen
+ if s.order == 0 { seg_color = Color.MintGreen } # brighter head
+ Screen.fill_rectangle(x: p.x * TILE + 1, y: p.y * TILE + 1, width: TILE - 2, height: TILE - 2, color: seg_color)
+ }
+ Screen.draw_text(x: 6, y: 4, text: "SCORE", color: Color.White, scale: 1)
+ Screen.draw_number(x: 52, y: 4, value: score, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
diff --git a/tools/docgen/README.md b/tools/docgen/README.md
new file mode 100644
index 00000000..3ce8ce8c
--- /dev/null
+++ b/tools/docgen/README.md
@@ -0,0 +1,67 @@
+# Ludic documentation generator
+
+Generates the public documentation site (the `pages` branch) from a single
+source of truth, so the site can never drift from the language.
+
+## Source of truth
+
+```
+docs/
+ language/`, ``) and `backtick code` both work.
+
+```ludic
+handler Move phase Update { … }
+```
+```
+
+Add such a file and it appears in the API Reference, is recognized + tipped +
+linked in **every** code snippet across the site, and lands in `symbols.json` —
+with no other file to edit.
+
+## Build
+
+```bash
+python3 tools/docgen/gen.py --out build/pages # generate the whole site
+python3 tools/docgen/check.py build/pages # sanity-check before publish
+```
+
+Outputs into `--out`: `index.html`, `api.html`, `ludic-highlight.js`
+(its symbol tables generated from the sources above), `symbols.json`, `.nojekyll`.
+
+No third-party dependencies — Python standard library only.
+
+## Publish
+
+`.forgejo/workflows/docs.yml` runs this on every push to `main` that touches
+`docs/**` or `tools/docgen/**`, and force-publishes the result to the `pages`
+branch root (which the pages-server serves). It needs a repo secret
+`PAGES_TOKEN` with write access (or the automatic Actions token enabled for
+pushes). `index.html` + `.nojekyll` always stay at the branch root.
+
+## Colors
+
+`palette.json` is generated by `tools/docgen/palette.py` from a single `PALETTE`
+table, which also generates `selfhost/emit_color.ludic`. Regenerate colors
+there; never hand-edit either output.
diff --git a/tools/docgen/assets/api.css b/tools/docgen/assets/api.css
new file mode 100644
index 00000000..72f21c2d
--- /dev/null
+++ b/tools/docgen/assets/api.css
@@ -0,0 +1,83 @@
+ :root{
+ --bg:#0a0d18; --bg2:#0d1122; --panel:#12172b; --panel2:#151b32;
+ --line:#232b47; --line2:#2c3660; --text:#c9d2ea; --head:#f2f5ff; --muted:#8290b4;
+ --mint:#7cf5c4; --coral:#ff5d73; --violet:#c792ea; --blue:#82aaff; --amber:#f6c177;
+ --c-bg:#0b0f20; --c-comment:#5b6788; --c-key:#ff7eb6; --c-type:#7cf5c4;
+ --c-str:#f6c177; --c-num:#a6b8ff; --c-annot:#c792ea; --c-fn:#82aaff; --c-punc:#9aa6cc;
+ --radius:14px; --max:1180px;
+ }
+ *{box-sizing:border-box}
+ html{scroll-behavior:smooth}
+ body{margin:0;background:var(--bg);color:var(--text);
+ font-family:"Space Grotesk",system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6;
+ -webkit-font-smoothing:antialiased}
+ a{color:inherit;text-decoration:none}
+ code,pre,.mono{font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace}
+ .wrap{max-width:var(--max);margin:0 auto;padding:0 24px}
+
+ header.nav{position:sticky;top:0;z-index:50;backdrop-filter:blur(10px);
+ background:rgba(10,13,24,.78);border-bottom:1px solid var(--line)}
+ .nav-in{display:flex;align-items:center;gap:22px;height:64px}
+ .brand{display:flex;align-items:center;gap:10px;font-weight:700;color:var(--head);font-size:18px}
+ .logo{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;
+ background:linear-gradient(135deg,var(--mint),#3fd7a6);color:#06231a;font-weight:700;font-size:15px}
+ .nav-links{display:flex;gap:24px;margin-left:auto;align-items:center}
+ .nav-links a{color:var(--muted);font-size:14.5px;font-weight:500;transition:color .15s}
+ .nav-links a:hover{color:var(--head)}
+ .nav-cta{border:1px solid var(--line2);padding:8px 15px;border-radius:9px;color:var(--head)!important;background:var(--panel)}
+ .nav-cta:hover{border-color:var(--mint)}
+ @media(max-width:760px){.nav-links a:not(.nav-cta){display:none}}
+
+ /* two-column reference layout */
+ .ref-layout{display:grid;grid-template-columns:230px 1fr;gap:40px;align-items:start;padding-top:34px;padding-bottom:80px}
+ @media(max-width:900px){.ref-layout{grid-template-columns:1fr}.side{display:none}}
+ .side{position:sticky;top:88px;display:flex;flex-direction:column;gap:2px;max-height:calc(100vh - 110px);overflow:auto}
+ .side a{color:var(--muted);font-size:13.5px;padding:6px 10px;border-radius:8px;border-left:2px solid transparent;transition:all .12s}
+ .side a:hover{color:var(--head);background:var(--panel)}
+ .side a.active{color:var(--mint);border-left-color:var(--mint);background:rgba(124,245,196,.06)}
+
+ .ref-intro{margin-bottom:44px}
+ .ref-intro .kicker{font-size:13px;font-weight:600;letter-spacing:2px;text-transform:uppercase;color:var(--mint);margin-bottom:12px}
+ .ref-intro h1{font-size:clamp(30px,5vw,46px);margin:0 0 12px;color:var(--head);letter-spacing:-1px;font-weight:700}
+ .ref-intro p{font-size:17px;color:var(--muted);max-width:640px;margin:0}
+
+ .ref-sec{padding:26px 0 10px;border-top:1px solid var(--line);margin-top:26px}
+ .ref-sec:first-of-type{border-top:none;margin-top:0}
+ .ref-sec h2{font-size:24px;color:var(--head);margin:0 0 6px;letter-spacing:-.4px}
+ .sec-blurb{color:var(--muted);font-size:15px;margin:0 0 22px;max-width:720px}
+ .sec-blurb code{color:var(--mint);background:rgba(124,245,196,.08);padding:1px 5px;border-radius:5px;font-size:12.5px}
+
+ .entry{padding:16px 0;border-top:1px dashed var(--line)}
+ .entry:first-of-type{border-top:none}
+ .entry-head{display:flex;align-items:center;gap:10px}
+ .entry h3{margin:0;font-size:16.5px;color:var(--head);font-weight:600;font-family:"JetBrains Mono",monospace}
+ .entry .anchor{color:var(--line2);font-size:15px;opacity:0;transition:opacity .12s}
+ .entry:hover .anchor{opacity:1}
+ .entry .anchor:hover{color:var(--mint)}
+ .entry .sig{display:inline-block;margin:8px 0 6px;color:var(--c-fn);background:var(--c-bg);
+ border:1px solid var(--line);border-radius:8px;padding:5px 10px;font-size:13px}
+ .entry p{margin:6px 0 0;color:var(--text);font-size:14.5px;max-width:720px}
+ .entry p code{color:var(--amber);background:var(--panel);padding:1px 5px;border-radius:5px;font-size:12.5px}
+ .entry p b{color:var(--head)}
+
+ pre{margin:10px 0 0;padding:14px 16px;overflow:auto;font-size:13px;line-height:1.7;tab-size:2;
+ background:var(--c-bg);border:1px solid var(--line);border-radius:10px}
+ pre.ex{max-width:720px}
+ pre::-webkit-scrollbar{height:8px}
+ pre::-webkit-scrollbar-thumb{background:var(--line2);border-radius:6px}
+ .t-com{color:var(--c-comment);font-style:italic}
+ .t-key{color:var(--c-key)}.t-type{color:var(--c-type)}.t-str{color:var(--c-str)}
+ .t-num{color:var(--c-num)}.t-annot{color:var(--c-annot)}.t-fn{color:var(--c-fn)}.t-punc{color:var(--c-punc)}
+ /* hover-jump links inside code */
+ a.tok{border-radius:3px;transition:background .12s}
+ a.tok:hover{background:rgba(124,245,196,.14);outline:1px solid rgba(124,245,196,.35)}
+
+ /* color swatches */
+ .swatch-group{margin-bottom:22px}
+ .swatch-group h4{margin:0 0 12px;font-size:13px;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);font-weight:600}
+ .swatch-row{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}
+ .swatch{display:flex;align-items:center;gap:10px;background:var(--panel);border:1px solid var(--line);
+ border-radius:9px;padding:8px 10px}
+ .chip{width:24px;height:24px;border-radius:6px;flex:none;box-shadow:inset 0 0 0 1px rgba(255,255,255,.12)}
+ .cname{font-family:"JetBrains Mono",monospace;font-size:12px;color:var(--head);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .chex{margin-left:auto;font-family:"JetBrains Mono",monospace;font-size:11px;color:var(--muted)}
diff --git a/tools/docgen/assets/ludic-highlight.tmpl.js b/tools/docgen/assets/ludic-highlight.tmpl.js
new file mode 100644
index 00000000..3185e044
--- /dev/null
+++ b/tools/docgen/assets/ludic-highlight.tmpl.js
@@ -0,0 +1,132 @@
+/* ============================================================================
+ * ludic-highlight.js — the Ludic syntax highlighter for the docs site.
+ *
+ * GENERATED FILE — do not edit by hand. The symbol tables below (keywords,
+ * types, phases, namespace methods, builtins, annotations and their one-line
+ * tips + anchors) are produced by tools/docgen/gen.py from the per-symbol
+ * source files in docs/language/**. Add a symbol there and it is recognized,
+ * colored, tipped and linked here automatically — nothing to maintain twice.
+ *
+ * Beyond coloring, every token the language defines becomes a link into the API
+ * Reference (api.html): hover a keyword, a Screen.* call, a named color, a
+ * builtin or a type and it points at the entry that explains it. Your own
+ * symbols (functions, entities, fields) stay plain.
+ * ========================================================================== */
+(function (global) {
+ "use strict";
+
+ const SYMBOLS = /*__SYMBOLS__*/{};
+ const KEYWORDS = SYMBOLS.keywords || {};
+ const TYPES = SYMBOLS.types || {};
+ const PHASES = SYMBOLS.phases || {};
+ const BUILTINS = SYMBOLS.builtins || {};
+ const NSMETHODS = SYMBOLS.nsmethods || {};
+ const ANNOTS = SYMBOLS.annotations || {};
+ const TIPS = SYMBOLS.tips || {};
+ const NAMESPACES = new Set(SYMBOLS.namespaces || []);
+ const COLORS_ANCHOR = SYMBOLS.colors_anchor || "colors";
+ const ANNOT_ANCHOR = SYMBOLS.annotations_anchor || "annotations";
+
+ function esc(s) {
+ return s.replace(/&/g, "&").replace(//g, ">");
+ }
+ const isIdStart = c => /[A-Za-z_]/.test(c);
+ const isId = c => /[A-Za-z0-9_]/.test(c);
+
+ function link(href, tip, inner) {
+ const t = tip ? ' title="' + esc(tip) + '"' : "";
+ return '" + inner + "";
+ }
+
+ function highlight(src) {
+ let out = "", i = 0;
+ const n = src.length;
+ while (i < n) {
+ const c = src[i];
+ // comment
+ if (c === "#") {
+ let j = i; while (j < n && src[j] !== "\n") j++;
+ out += '' + esc(src.slice(i, j)) + "";
+ i = j; continue;
+ }
+ // string / char / interpolation (backtick)
+ if (c === '"' || c === "'" || c === "`") {
+ const q = c; let j = i + 1;
+ while (j < n && src[j] !== q) { if (src[j] === "\\") j++; j++; }
+ j = Math.min(j + 1, n);
+ out += '' + esc(src.slice(i, j)) + "";
+ i = j; continue;
+ }
+ // annotation @Name
+ if (c === "@") {
+ let j = i + 1; while (j < n && isId(src[j])) j++;
+ const at = src.slice(i, j);
+ const anchor = ANNOTS[at] || ANNOT_ANCHOR;
+ const tip = TIPS[at] || "A compile-time annotation.";
+ out += link("api.html#" + anchor, tip,
+ '' + esc(at) + "");
+ i = j; continue;
+ }
+ // number (incl 0x hex)
+ if (/[0-9]/.test(c)) {
+ let j = i; while (j < n && /[0-9a-fA-FxX._]/.test(src[j])) j++;
+ out += '' + esc(src.slice(i, j)) + "";
+ i = j; continue;
+ }
+ // identifier / keyword / type / namespace.member / builtin
+ if (isIdStart(c)) {
+ let j = i; while (j < n && isId(src[j])) j++;
+ const word = src.slice(i, j);
+
+ // Namespace.member (Screen.fill_rectangle, Color.Crimson, …)
+ if (NAMESPACES.has(word) && src[j] === "." && j + 1 < n && isIdStart(src[j + 1])) {
+ let k = j + 1; while (k < n && isId(src[k])) k++;
+ const member = src.slice(j + 1, k);
+ const key = word + "." + member;
+ let href, tip;
+ if (word === "Color") {
+ href = "api.html#" + COLORS_ANCHOR; tip = "Named color " + key + ".";
+ } else {
+ href = "api.html#" + (NSMETHODS[key] || (word.toLowerCase() + "-" + member));
+ tip = TIPS[key] || key;
+ }
+ const inner = '' + word + '.' + esc(member) + "";
+ out += link(href, tip, inner);
+ i = k; continue;
+ }
+
+ let k2 = j; while (k2 < n && src[k2] === " ") k2++;
+ const callish = src[k2] === "(";
+ if (KEYWORDS[word]) {
+ out += link("api.html#" + KEYWORDS[word], TIPS[word], '' + word + "");
+ } else if (BUILTINS[word] && callish) {
+ out += link("api.html#" + BUILTINS[word], TIPS[word], '' + word + "");
+ } else if (TYPES[word]) {
+ out += link("api.html#" + TYPES[word], TIPS[word] || ("The " + word + " type."), '' + word + "");
+ } else if (PHASES[word]) {
+ out += link("api.html#" + PHASES[word], "The " + word + " phase.", '' + word + "");
+ } else if (callish) {
+ out += '' + esc(word) + "";
+ } else {
+ out += esc(word);
+ }
+ i = j; continue;
+ }
+ // punctuation
+ if (/[{}\[\]()=<>+\-*\/%,.:;!&|~]/.test(c)) {
+ out += '' + esc(c) + "";
+ i++; continue;
+ }
+ out += esc(c); i++;
+ }
+ return out;
+ }
+
+ function highlightAll() {
+ document.querySelectorAll('pre[data-lang="ludic"]').forEach(pre => {
+ pre.innerHTML = highlight(pre.textContent);
+ });
+ }
+
+ global.Ludic = { highlight, highlightAll, SYMBOLS };
+})(window);
diff --git a/tools/docgen/assets/site.css b/tools/docgen/assets/site.css
new file mode 100644
index 00000000..8b7b10cb
--- /dev/null
+++ b/tools/docgen/assets/site.css
@@ -0,0 +1,194 @@
+ :root{
+ --bg:#0a0d18;
+ --bg2:#0d1122;
+ --panel:#12172b;
+ --panel2:#151b32;
+ --line:#232b47;
+ --line2:#2c3660;
+ --text:#c9d2ea;
+ --head:#f2f5ff;
+ --muted:#8290b4;
+ --mint:#7cf5c4;
+ --coral:#ff5d73;
+ --violet:#c792ea;
+ --blue:#82aaff;
+ --amber:#f6c177;
+ --peri:#a6b8ff;
+ /* code theme */
+ --c-bg:#0b0f20;
+ --c-comment:#5b6788;
+ --c-key:#ff7eb6;
+ --c-type:#7cf5c4;
+ --c-str:#f6c177;
+ --c-num:#a6b8ff;
+ --c-annot:#c792ea;
+ --c-fn:#82aaff;
+ --c-punc:#9aa6cc;
+ --radius:14px;
+ --max:1120px;
+ }
+ *{box-sizing:border-box}
+ html{scroll-behavior:smooth}
+ body{
+ margin:0;
+ background:
+ radial-gradient(1100px 600px at 82% -8%, rgba(124,245,196,.10), transparent 60%),
+ radial-gradient(900px 620px at 6% 4%, rgba(255,93,115,.09), transparent 55%),
+ var(--bg);
+ color:var(--text);
+ font-family:"Space Grotesk", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+ line-height:1.6;
+ -webkit-font-smoothing:antialiased;
+ text-rendering:optimizeLegibility;
+ }
+ a{color:inherit;text-decoration:none}
+ code,pre,.mono{font-family:"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace}
+ .wrap{max-width:var(--max);margin:0 auto;padding:0 24px}
+
+ /* ---------- nav ---------- */
+ header.nav{position:sticky;top:0;z-index:50;backdrop-filter:blur(10px);
+ background:rgba(10,13,24,.72);border-bottom:1px solid var(--line)}
+ .nav-in{display:flex;align-items:center;gap:22px;height:64px}
+ .brand{display:flex;align-items:center;gap:10px;font-weight:700;color:var(--head);font-size:18px;letter-spacing:.2px}
+ .logo{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;
+ background:linear-gradient(135deg,var(--mint),#3fd7a6);color:#06231a;font-weight:700;font-size:15px;
+ box-shadow:0 0 0 1px rgba(124,245,196,.3), 0 6px 20px -6px rgba(124,245,196,.5)}
+ .nav-links{display:flex;gap:24px;margin-left:auto;align-items:center}
+ .nav-links a{color:var(--muted);font-size:14.5px;font-weight:500;transition:color .15s}
+ .nav-links a:hover{color:var(--head)}
+ .nav-cta{border:1px solid var(--line2);padding:8px 15px;border-radius:9px;color:var(--head)!important;
+ background:var(--panel);transition:border-color .15s, background .15s}
+ .nav-cta:hover{border-color:var(--mint);background:var(--panel2)}
+ @media(max-width:760px){.nav-links a:not(.nav-cta){display:none}}
+
+ /* ---------- hero ---------- */
+ .hero{padding:84px 0 56px;position:relative;overflow:hidden}
+ .pill{display:inline-flex;align-items:center;gap:9px;font-size:13px;color:var(--mint);
+ border:1px solid rgba(124,245,196,.28);background:rgba(124,245,196,.06);
+ padding:6px 13px;border-radius:999px;font-weight:500;margin-bottom:26px}
+ .pill .dot{width:7px;height:7px;border-radius:50%;background:var(--mint);box-shadow:0 0 8px var(--mint)}
+ h1{font-size:clamp(38px,6vw,68px);line-height:1.04;margin:0 0 20px;color:var(--head);
+ font-weight:700;letter-spacing:-1.5px}
+ h1 .accent{background:linear-gradient(120deg,var(--mint),var(--blue));-webkit-background-clip:text;
+ background-clip:text;-webkit-text-fill-color:transparent}
+ .lead{font-size:clamp(17px,2.3vw,21px);color:var(--muted);max-width:640px;margin:0 0 34px}
+ .lead b{color:var(--text);font-weight:600}
+ .cta-row{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
+ .btn{display:inline-flex;align-items:center;gap:9px;font-weight:600;font-size:15.5px;
+ padding:13px 22px;border-radius:11px;transition:transform .12s, box-shadow .15s, border-color .15s;font-family:inherit;cursor:pointer;border:1px solid transparent}
+ .btn:active{transform:translateY(1px)}
+ .btn-primary{background:linear-gradient(135deg,var(--mint),#43dcae);color:#06231a;
+ box-shadow:0 10px 30px -10px rgba(124,245,196,.6)}
+ .btn-primary:hover{box-shadow:0 14px 38px -10px rgba(124,245,196,.75)}
+ .btn-ghost{border:1px solid var(--line2);color:var(--head);background:var(--panel)}
+ .btn-ghost:hover{border-color:var(--mint)}
+ .hero-grid{display:grid;grid-template-columns:1.02fr .98fr;gap:48px;align-items:center}
+ @media(max-width:900px){.hero-grid{grid-template-columns:1fr;gap:36px}}
+
+ /* window chrome for code */
+ .code-card{background:var(--c-bg);border:1px solid var(--line);border-radius:var(--radius);
+ overflow:hidden;box-shadow:0 30px 60px -30px rgba(0,0,0,.7)}
+ .code-top{display:flex;align-items:center;gap:8px;padding:12px 15px;border-bottom:1px solid var(--line);
+ background:linear-gradient(180deg,var(--panel),rgba(18,23,43,.4))}
+ .tl{width:11px;height:11px;border-radius:50%}
+ .tl.r{background:#ff5f57}.tl.y{background:#febc2e}.tl.g{background:#28c840}
+ .code-name{margin-left:8px;font-size:12.5px;color:var(--muted)}
+ pre{margin:0;padding:20px 22px;overflow:auto;font-size:13.5px;line-height:1.72;tab-size:2}
+ pre::-webkit-scrollbar{height:9px;width:9px}
+ pre::-webkit-scrollbar-thumb{background:var(--line2);border-radius:6px}
+ /* token colors */
+ .t-com{color:var(--c-comment);font-style:italic}
+ .t-key{color:var(--c-key)}
+ .t-type{color:var(--c-type)}
+ .t-str{color:var(--c-str)}
+ .t-num{color:var(--c-num)}
+ .t-annot{color:var(--c-annot)}
+ .t-fn{color:var(--c-fn)}
+ .t-punc{color:var(--c-punc)}
+ /* hover-to-jump links inside code snippets */
+ pre a.tok{border-radius:3px;transition:background .12s, outline-color .12s}
+ pre a.tok:hover{background:rgba(124,245,196,.14);outline:1px solid rgba(124,245,196,.35)}
+
+ /* ---------- pipeline ---------- */
+ .pipeline{display:flex;align-items:center;justify-content:center;gap:0;flex-wrap:wrap;
+ margin:8px 0 0;padding:20px;border:1px dashed var(--line2);border-radius:var(--radius);
+ background:rgba(18,23,43,.4)}
+ .stage{font-family:"JetBrains Mono",monospace;font-size:13px;font-weight:500;color:var(--head);
+ background:var(--panel);border:1px solid var(--line2);padding:8px 13px;border-radius:9px;white-space:nowrap}
+ .stage.hl{color:var(--mint);border-color:rgba(124,245,196,.35)}
+ .arrow{color:var(--muted);padding:0 12px;font-size:15px}
+
+ /* ---------- sections ---------- */
+ section{padding:72px 0}
+ .sec-head{max-width:680px;margin-bottom:44px}
+ .kicker{font-size:13px;font-weight:600;letter-spacing:2px;text-transform:uppercase;color:var(--mint);margin-bottom:14px}
+ h2{font-size:clamp(28px,4vw,40px);margin:0 0 14px;color:var(--head);letter-spacing:-.8px;font-weight:700;line-height:1.1}
+ .sec-head p{font-size:17.5px;color:var(--muted);margin:0}
+
+ /* feature grid */
+ .feat-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
+ @media(max-width:900px){.feat-grid{grid-template-columns:repeat(2,1fr)}}
+ @media(max-width:600px){.feat-grid{grid-template-columns:1fr}}
+ .feat{background:linear-gradient(180deg,var(--panel),var(--bg2));border:1px solid var(--line);
+ border-radius:var(--radius);padding:24px 22px;transition:transform .16s, border-color .16s}
+ .feat:hover{transform:translateY(-3px);border-color:var(--line2)}
+ .feat .ico{width:40px;height:40px;border-radius:10px;display:grid;place-items:center;margin-bottom:16px;
+ background:rgba(124,245,196,.09);border:1px solid rgba(124,245,196,.2);font-size:20px}
+ .feat h3{margin:0 0 8px;font-size:17.5px;color:var(--head);font-weight:600}
+ .feat p{margin:0;font-size:14.5px;color:var(--muted);line-height:1.6}
+ .feat p code{color:var(--mint);font-size:12.5px;background:rgba(124,245,196,.08);padding:1px 5px;border-radius:5px}
+
+ /* showcase tabs */
+ .tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:18px}
+ .tab{font-family:"JetBrains Mono",monospace;font-size:13px;color:var(--muted);background:var(--panel);
+ border:1px solid var(--line);padding:8px 15px;border-radius:9px;cursor:pointer;transition:all .14s;font-weight:500}
+ .tab:hover{color:var(--text);border-color:var(--line2)}
+ .tab.active{color:#06231a;background:var(--mint);border-color:var(--mint);font-weight:600}
+ .panel-code{display:none}
+ .panel-code.active{display:block}
+ .showcase-note{margin-top:16px;font-size:14px;color:var(--muted);display:flex;gap:10px;align-items:flex-start}
+ .showcase-note .b{color:var(--mint);font-weight:700}
+
+ /* steps */
+ .steps{display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:start}
+ @media(max-width:900px){.steps{grid-template-columns:1fr}}
+ .step{display:flex;gap:16px;margin-bottom:26px}
+ .step .n{flex:none;width:32px;height:32px;border-radius:9px;display:grid;place-items:center;font-weight:700;
+ font-family:"JetBrains Mono",monospace;font-size:14px;color:var(--mint);
+ background:rgba(124,245,196,.08);border:1px solid rgba(124,245,196,.25)}
+ .step h4{margin:2px 0 6px;color:var(--head);font-size:16.5px;font-weight:600}
+ .step p{margin:0;color:var(--muted);font-size:14.5px}
+ .step p code{color:var(--text);background:var(--panel);padding:1px 6px;border-radius:5px;font-size:12.5px;border:1px solid var(--line)}
+
+ .term{background:var(--c-bg);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;position:sticky;top:88px}
+ .term pre{font-size:13px}
+ .term .prompt{color:var(--mint)}
+ .term .out{color:var(--muted)}
+
+ /* editors strip */
+ .editors{display:flex;flex-wrap:wrap;gap:12px}
+ .ed{font-family:"JetBrains Mono",monospace;font-size:13.5px;color:var(--text);background:var(--panel);
+ border:1px solid var(--line);border-radius:10px;padding:11px 16px;display:flex;align-items:center;gap:9px}
+ .ed .k{color:var(--mint)}
+
+ /* callout / philosophy banner */
+ .banner{background:linear-gradient(120deg,rgba(124,245,196,.08),rgba(130,170,255,.06));
+ border:1px solid var(--line2);border-radius:18px;padding:38px 34px;display:grid;grid-template-columns:1.2fr 1fr;gap:34px;align-items:center}
+ @media(max-width:860px){.banner{grid-template-columns:1fr}}
+ .banner h2{font-size:clamp(24px,3.4vw,32px)}
+ .banner p{color:var(--muted);margin:0 0 6px;font-size:15.5px}
+ .stat-row{display:flex;gap:14px;flex-wrap:wrap}
+ .stat{background:var(--c-bg);border:1px solid var(--line);border-radius:12px;padding:16px 18px;flex:1;min-width:130px}
+ .stat .big{font-family:"JetBrains Mono",monospace;font-size:26px;color:var(--mint);font-weight:700;line-height:1}
+ .stat .lbl{font-size:12.5px;color:var(--muted);margin-top:8px}
+
+ /* footer */
+ footer{border-top:1px solid var(--line);padding:44px 0 60px;margin-top:20px}
+ .foot-in{display:flex;justify-content:space-between;gap:24px;flex-wrap:wrap;align-items:center}
+ .foot-in .muted{color:var(--muted);font-size:14px}
+ .foot-links{display:flex;gap:22px;flex-wrap:wrap}
+ .foot-links a{color:var(--muted);font-size:14px;transition:color .15s}
+ .foot-links a:hover{color:var(--mint)}
+
+ .reveal{opacity:0;transform:translateY(16px);transition:opacity .6s ease, transform .6s ease}
+ .reveal.in{opacity:1;transform:none}
diff --git a/tools/docgen/check.py b/tools/docgen/check.py
new file mode 100644
index 00000000..57a65ce6
--- /dev/null
+++ b/tools/docgen/check.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""check.py — sanity-check a generated docs site before it is published.
+
+Verifies:
+ * the pages-server contract: index.html + .nojekyll exist at the root;
+ * api.html and ludic-highlight.js are present;
+ * every anchor the highlighter links to actually exists in api.html
+ (so no code-snippet token points at a missing entry).
+
+Usage: python3 tools/docgen/check.py code (leaves existing tags alone)
+ desc = re.sub(r"`([^`]+)`", r"\1", desc)
+ paras = [p.strip() for p in re.split(r"\n\s*\n", desc) if p.strip()]
+ desc_html = "
".join(paras)
+ return desc_html, examples
+
+# ---------------------------------------------------------------------------
+# load the symbol model
+# ---------------------------------------------------------------------------
+def load_sections():
+ sections = []
+ for cat in sorted(os.listdir(LANG)):
+ cdir = os.path.join(LANG, cat)
+ if not os.path.isdir(cdir):
+ continue
+ smeta, sblurb = {}, ""
+ secpath = os.path.join(cdir, "_section.md")
+ if os.path.exists(secpath):
+ smeta, sbody = parse_doc(secpath)
+ sblurb = re.sub(r"`([^`]+)`", r"\1", sbody.strip())
+ entries = []
+ for fn in os.listdir(cdir):
+ if not fn.endswith(".md") or fn == "_section.md":
+ continue
+ meta, body = parse_doc(os.path.join(cdir, fn))
+ desc, examples = split_body(body)
+ meta["desc_html"] = desc
+ meta["examples"] = examples
+ meta["tokens_list"] = meta.get("tokens", "").split() if meta.get("tokens") else []
+ entries.append(meta)
+ entries.sort(key=lambda e: (int(e.get("order", 999)), e.get("name", "")))
+ sections.append({
+ "id": smeta.get("id", cat),
+ "title": smeta.get("title", cat.title()),
+ "order": int(smeta.get("order", 999)),
+ "blurb": sblurb,
+ "entries": entries,
+ })
+ sections.sort(key=lambda s: (s["order"], s["title"]))
+ return sections
+
+# ---------------------------------------------------------------------------
+# build the highlighter symbol tables from the model
+# ---------------------------------------------------------------------------
+def build_symbols(sections):
+ sym = {"keywords": {}, "types": {}, "phases": {}, "builtins": {},
+ "nsmethods": {}, "annotations": {}, "tips": {},
+ "namespaces": [], "colors_anchor": "colors", "annotations_anchor": "annotations"}
+ namespaces = set()
+ for s in sections:
+ for e in s["entries"]:
+ kind = e.get("kind", "")
+ anchor = e["id"]
+ tip = e.get("tip", "")
+ for tok in e["tokens_list"]:
+ if kind == "keyword":
+ sym["keywords"][tok] = anchor
+ elif kind == "type":
+ sym["types"][tok] = anchor
+ elif kind == "phase":
+ sym["phases"][tok] = anchor
+ elif kind == "builtin":
+ sym["builtins"][tok] = anchor
+ elif kind == "namespace-method":
+ sym["nsmethods"][tok] = anchor
+ if "." in tok:
+ namespaces.add(tok.split(".", 1)[0])
+ elif kind == "annotation":
+ sym["annotations"][tok] = anchor
+ if tip:
+ sym["tips"][tok] = tip
+ if s["id"] == "colors":
+ sym["colors_anchor"] = "colors"
+ if s["id"] == "annotations":
+ sym["annotations_anchor"] = "annotations"
+ namespaces.add("Color") # Color.* is recognized and linked to the palette
+ sym["namespaces"] = sorted(namespaces)
+ return sym
+
+# ---------------------------------------------------------------------------
+# render the API Reference
+# ---------------------------------------------------------------------------
+def render_entry(e):
+ ex = ""
+ for code in e.get("examples", []):
+ ex += '
' + esc(code) + "" + desc = e.get("desc_html", "") + return ( + '' + ).format(id=e["id"], name=esc(e.get("name", "")), sig=esc(e.get("sig", "")), + desc=desc, ex=ex) + +def render_palette(palette): + out = ['
{blurb}
{body}Every keyword, type, builtin, namespace and color in Ludic. In any code sample across this site, hover a token and click to jump straight to its entry here.
+%s
%s
" % p for p in phil["paras"]) + phil_stats = "".join('%s
%(hero_code)s+
%(pipenote)s
+%(feat_intro)s
+%(sc_intro)s
+%(start_intro)s
+%(term)s+
%(ed_intro)s
+%(ed_note)s
+