Skip to content

Repository files navigation

Studio

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 .

Running it

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/I pick pencil, eraser, bucket, line, rectangle and picker; S/X are the map's stamp and rubber.
  • Ctrl+Z / Ctrl+Shift+Z undo and redo, Ctrl+N a new sprite, Ctrl+M a new map.
  • Ctrl+' toggles the pixel grid, which is off by default.
  • Ctrl+= and Ctrl+- change the UI scale, and it is remembered.
  • Ctrl+, opens Preferences: theme, interface scale, pixel grid.

Themes

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.

The playground

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.

Icons and cursors

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').

Changing the font

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.

Tests and checks

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.fieldand/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.

Pixel matching

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.

Layout

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

Conventions

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 Studio context object is constructed in load() 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.

Status

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).

Reading

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages