A toolkit for making game assets, written in Ghost and running on Lumen. Nothing else — no bindings, no build step, no dependencies.
Studio is two things stacked:
- Chisel — a GUI framework: rectangles, painting, widgets, layout, input. It knows nothing about editors.
- Studio — an editor shell: documents, commands, a keymap, tools, preferences. It knows nothing about sprites or maps.
On top of those sit the editors. A pixel editor and a map editor ship today; a sound editor is designed for and blocked on engine work (see Papercuts).
The look is Aseprite's construction — pixel bevels, integer UI scale, docked panels, chrome that recedes so the artwork is the loudest thing on screen — with corners following a circular quadrant in whole pixels rather than left square, and a palette that is closer to Ghost in the Shell than to a grey toolbar: near-black grounds with a violet cast, neon purple for anything selected, cyan held back for focus. The saturated trio for good/careful/stop is Mario's green, a coin's gold and Mario red, which are as legible together as three colours get — and the default sprite palette is the NES's Mario sixteen.
lumen .
Download Lumen from ghostlang.org/download, put it
on your PATH, and point it at this folder. main.gs opens a 32×32 sprite and a 24×16
map, with tabs to switch between them.
- Draw with the left button, background colour with the right.
- Scroll to zoom, middle-drag to pan.
B/E/G/L/U/Ipick pencil, eraser, bucket, line, rectangle and picker;S/Xare the map's stamp and rubber.Ctrl+Z/Ctrl+Shift+Zundo and redo,Ctrl+Na new sprite,Ctrl+Ma new map.Ctrl+'toggles the pixel grid, which is off by default.Ctrl+=andCtrl+-change the UI scale, and it is remembered.Ctrl+,opens Preferences: theme, interface scale, pixel grid.
Four ship, switchable live from Preferences and remembered between runs:
| name | |
|---|---|
ghost.dark |
the default — violet-cast near-black, neon purple selection |
ghost.light |
the same accent over a cool paper grey |
aseprite.dark |
neutral greys, amber accent |
aseprite.classic |
the grey-and-teal original |
A theme is one function returning a Theme of role tokens (chisel/themes/), so adding
one is a twenty-line file plus a line in theme-named.gs. Switching rebuilds the workspace,
because the dock's region sizes come from the theme's metrics — the colours alone would
land without it, the sizes would not.
On macOS every Ctrl+ binding answers to Cmd+ too — ctrl in a binding means "the
platform's accelerator", and literal Ctrl keeps working there as well.
lumen playground.gs
Every control in the kit on one screen, driven by real input: buttons, checkboxes,
radios, sliders, scrollbars, text fields, dropdowns, tabs, labels, icon buttons and the
palette. It imports chisel/ and nothing else — no Studio, no documents — which is the
point: the framework has to stand on its own before an application leans on it.
Build widgets here first. A bevel is only obviously wrong when you can see it beside twenty others, and a theme change lands everywhere at once where you can watch it.
The art lives in tools/make-icons.py as ASCII, and the PNGs are build output:
python3 tools/make-icons.py
Keeping it that way means changing an icon is a readable diff rather than a binary blob nobody can review.
Picotron uses two icon languages, and the difference is not decorative:
resources/icons.png |
resources/glyphs.png |
|
|---|---|---|
| What | tools, toolbar buttons, arrows | document, sprite, map, sound, palette, cartridge, font, folder |
| Cell | 8 × 8 px (7×7 of art) | 16 × 16 px (15×16 of art) |
| Colour | white on transparent, tinted at draw | full colour, shipped as drawn |
| Outline | none | 1px #1d2b53 |
Control icons are monochrome silhouettes, so one sheet serves the normal, dimmed, hovered and selected states and a theme swap recolours all of them at once. File icons are pictures, not symbols — tinting one would flatten it to a silhouette and destroy it.
The glyph sheet is Picotron's own art, lifted pixel for pixel from its icon browser, which shows the file-type set at 1:1 on a 480×270 screen. Every colour in it resolves to a palette entry at distance nought, which is the check that the transcription is exact rather than close.
The size matters as much as the style. Picotron's control icons are 7×7 and its rows are 12px tall. The old sheet used 16×16 cells, which is taller than the row an icon sits in — most of why it could never have looked right no matter how well it was drawn.
Cursors additionally need a hotspot — the pixel that is actually "the
point". Picotron's pointer is a hollow outline rather than a filled arrow with
a border, so every interior pixel is whatever is behind it. It is reproduced
here pixel for pixel: tools/pixelmatch.py puts it at 100% against the
reference.
new Cursors('resources/cursors.png', 8)
.define('arrow', 1, 0)
.define('crosshair', 3, 3)
Lumen has no cursor API beyond showing and hiding the system pointer, so the
cursor is drawn by us, as pixel art, like everything else. A widget asks for one
during paint (ui.cursor('crosshair')); the request lasts one frame, so moving
away restores the arrow with nobody having to undo it.
To add art: add an entry to the matching table in tools/make-icons.py, run it,
and add the name to the define() list. It is then available as
ui.icons.drawIn(name, rect, tint, scale) or on any button via .icon('name').
A pixel font is only crisp at whole multiples of its native size — if the engine antialiases it. Blended text puts grey fringing wherever a glyph edge lands off the pixel grid, which is the whole of "why does the text look blurry".
Lumen's bundled silver.ttf has unitsPerEm 1900 on a 100-unit glyph grid, so blended
it is exact at 19px, 38px and 57px and blurry at everything between.
That was a property of the rasteriser, not of the font, and it is fixed. ghost-language/lumen#21 is merged: the built-in font is drawn with hard edges rather than blended, so every size is crisp — measured at zero mid-grey pixels from 8px to 38px. The theme draws at 16 × ui.scale accordingly, with metrics sized around the 8px cap height that produces.
On a Lumen older than that PR, only multiples of 19 are sharp; set theme.native back to
19 if you are pinned to one.
To use your own, set two preferences — or call theme.loadFonts(path, native) directly:
ui.font path to a .ttf, resolved against the app directory
ui.fontSize the size that font is drawn at natively
To find that number for a font you have: divide its unitsPerEm by the grid its glyph
coordinates are multiples of. A font built on an 8px design grid is crisp at 8, 16 and 24
and blurry at 12. Getting this wrong is the only way to make the interface blurry; getting
it right is the only way to make it sharp.
A font you supply yourself still keeps its smoothing, so ui.fontSize has to be that
font's own design size or its text will blur.
The cap metrics in Theme (capTop, cap, baseline, descender) are measured per
size from a real render — text is centred on the cap band, not the em box, which is what
makes a row of chrome look optically centred. Changing native means measuring them
again.
The engine-independent half — geometry, corner profiles, the widget tree, the dock, commands, keymap, modifiers, tools, signals, history, line drawing — runs under plain Ghost with no window, and exits non-zero on a failed assertion so it can gate a build:
ghost test.gs # 132 assertions
python3 tools/lint.py
The linter covers what the tests structurally cannot. Anything importing a lumen: module
is invisible to ghost test.gs, and that is exactly where every bug that reached a real run
of this app has lived. It checks the three mistakes that actually shipped:
| check | the bug it catches |
|---|---|
arity |
a call passing fewer arguments than a callable requires — Ghost needs a default on every optional parameter (shipped 3×) |
guards |
x == null or x.field — and/or do not short-circuit, so the guarded side is dereferenced anyway (shipped 2×) |
shadow |
a method whose name matches one of its file's imports, which it shadows for the whole class (shipped 1×) |
Both run in CI on every push, along with a parse sweep over every .gs file.
Neither of those can see a pixel. The interface is being rebuilt against Picotron, and "looks right" is not a check, so there is a third one that renders the real thing and holds it against regions cropped from Picotron's own screenshots:
tools/verify.sh # renders headlessly, compares every tile, exits non-zero
It runs verify.gs through Xvfb, reads the framebuffer back, and compares in
palette space — because most Picotron screenshots are not colour-accurate.
Three of the five references here come through a pipeline that darkens every
channel by up to 9, so a pixel-perfect reproduction scores 48.8% against them on
raw RGB. tools/pixelmatch.py carries the measured capture map, detects which
profile fits an image, and matches within it. docs/picotron.md
has the whole story, including why snapping to the nearest palette entry is the
wrong fix.
The gate is real: changing the corner chamfer from 2px to 3px fails five of the eight tiles.
Widgets, painting and documents need a running engine; the chrome is covered by the tiles above and the rest is exercised in the app.
main.gs the pixel editor: forwarding calls into the toolkit
playground.gs the widget gallery — chisel only, no application
test.gs the test entry point
chisel/ the GUI framework
support/ one function per file: snap, normalizeChord
geometry/rect.gs Rect — the unit of layout
traits/ one trait per file: Conditionable, Tappable, EmitsEvents
theme.gs painter.gs every colour and metric; every canvas call
pointer.gs modifiers.gs input state, portable modifier tracking
ui.gs widget.gs the frame, overlays, the base class
icons.gs cursors.gs sprite-sheet art and the software pointer
themes/ one function per theme
layout/ dock, row, column
widgets/ panel button label checkbox radio slider scrollbar
field dropdown tabs menu menubar toolbar swatches
statusbar ruler
studio/ the editor shell
studio.gs the context object handed to everything
signals.gs preferences.gs commands.gs
command.gs command-registry.gs keymap.gs
tool.gs tool-registry.gs history.gs
viewport.gs the document, drawn — shared by both editors
traits/editable.gs the document contract
sprite/ the pixel editor: colour bar, timeline, tools
map/ the map editor
playground/gallery.gs every control on one screen
resources/ icons.png, cursors.png — placeholder art
docs/tutorial.html how all of it was built, from first principles
docs/papercuts.md what Ghost and Lumen made hard, and what would fix it
Written up in full in CLAUDE.md. The short version:
- One class per file, one trait per file, one function per helper file, named after what is inside it.
- State lives on objects. Not taste: a Ghost function cannot assign to a variable outside itself, so module-level mutable state silently does not work.
- No service container. A
Studiocontext object is constructed inload()and passed down; things that really are looked up by name at runtime — commands, tools — get their own typed registry. - Dependencies point one way: editors → studio → chisel → lumen. Chisel holds one
opaque back-reference (
ui.studio) and never calls into it.
Working: the dock and every widget listed above, the theme and painter, capture-based input, tooltips, menus driven by commands, the keymap with guards, undo/redo, both editors, preferences that persist.
Not yet: splitters and saved panel layout, a command palette, damage rectangles instead of whole-document repaints, file open/save (blocked — Lumen has no file dialogs and its filesystem module is sandboxed to the save directory), and the sound editor (blocked — Lumen's audio API exposes no samples and no playhead).
docs/picotron.md is the current spec: every measurement the interface is built against, how they were taken, and which of them are exact.
docs/tutorial.html builds the repository from an empty folder, in order, explaining the reasoning as it goes. Open it in a browser. Its code blocks are checked in CI: every file the reader is told to type is parsed on every commit, because a tutorial whose code has quietly stopped working is worse than no tutorial.