Getting Started
What is Lumen?
Lumen is a lightweight 2D game engine for Ghost. It gives Ghost a game loop, a renderer, input, audio, and file access, so a whole game can be written in Ghost and nothing else.
It is built on SDL2, and gives a game a callback-driven loop, a transform stack, and a drawing model that stays out of the way.
import "lumen:keyboard"
import { Image } from "lumen:image"
game = { x: 100, y: 100 }
function load() {
game.sprite = new Image('resources/player.png')
}
function update(dt) {
if (keyboard.isDown('right', 'd')) {
game.x = game.x + 120 * dt
}
}
function draw() {
game.sprite.draw(game.x, game.y)
}
Installing Lumen
Homebrew
On a Mac, Lumen installs from the project's own tap — the same one Ghost lives in:
brew tap ghost-language/tap
brew install --cask ghost-language/tap/lumen
Homebrew asks you to trust a tap outside its own before it will load anything from it. Trusting it once covers every cask in it, so if you already did this for Ghost there is nothing to do:
brew trust --tap ghost-language/tap
Ghost is compiled into the Lumen binary and SDL2 travels alongside it inside the cask, with the libraries' install names rewritten to point beside the executable. So there is nothing else to install, and nothing to build. Upgrading later is brew upgrade --cask lumen.
Other platforms
Casks are macOS-only. Builds for Linux and Windows are on the download page, which lists everything attached to the latest release and picks out your platform.
On Windows the SDL2 DLLs are in the archive beside lumen.exe; keep the folder together. On Linux, SDL2 is linked dynamically and comes from your distribution, so the runtime libraries have to be installed:
apt install libsdl2-2.0-0 libsdl2-image-2.0-0 libsdl2-ttf-2.0-0 libsdl2-mixer-2.0-0
Building from Source
Lumen links against SDL2 through cgo, so building it needs the SDL development libraries — the headers, not just the runtime libraries a downloaded build asks for:
- SDL2
- SDL2_image
- SDL2_ttf
- SDL2_mixer
On macOS:
brew install sdl2 sdl2_image sdl2_ttf sdl2_mixer
On Debian or Ubuntu:
apt install libsdl2-dev libsdl2-image-dev libsdl2-ttf-dev libsdl2-mixer-dev
With those in place, clone and build it from GitHub:
git clone https://github.com/ghost-language/lumen
cd lumen
make build
Ghost is expected as a sibling checkout (../ghost), which is what the replace directive in Lumen's go.mod points at. Clone both side by side.
make build produces dist/lumen. make run EXAMPLE=60_rpg builds and runs one of the bundled examples, and make examples starts every example briefly and reports any that fail.
Running a Game
A game is a folder containing a main.gs.
lumen main.gs # run a game from its entry file
lumen examples/60_rpg # run the main.gs inside a directory
lumen game.lumen # run a packaged game
lumen # run the main.gs beside the binary, or in the
# working directory
| Flag | Description |
|---|---|
-h | Show help. |
-v | Show the version and exit. |
-o | Output path, for build and package. |
Asset paths — new Image(path), new Font(path, size), new Source(path), filesystem.readAsset — resolve relative to the directory the entry file is in.
The window opens at 800×600, titled "Lumen", targeting 60 frames a second. window.setMode(), window.setTitle(), and lumen.setTargetFps() change all three.
Your First Game
Create a folder with a single main.gs in it:
import "lumen:canvas"
import "lumen:color"
import "lumen:keyboard"
import "lumen:window"
game = { x: 100, y: 100, speed: 180 }
function load() {
window.setTitle('My First Game')
}
function update(dt) {
if (keyboard.isDown('right', 'd')) { game.x = game.x + game.speed * dt }
if (keyboard.isDown('left', 'a')) { game.x = game.x - game.speed * dt }
if (keyboard.isDown('down', 's')) { game.y = game.y + game.speed * dt }
if (keyboard.isDown('up', 'w')) { game.y = game.y - game.speed * dt }
}
function draw() {
canvas.setColor(color.white)
canvas.filledRectangle(game.x, game.y, 32, 32)
}
Run it with lumen . from inside that folder.
Three things in there are worth noticing now, because they come up in every Lumen game:
- Every module the file uses is imported at the top. Nothing is global.
- Movement is multiplied by
dt, the time the previous frame took. See the game loop. - The state lives on the
gamemap rather than in loose variables, because assignment inside a function is local and would not otherwise stick.
Modules and Imports
Ghost's standard library is import-only — console and type are the only names reachable without one — and Lumen's own modules follow the same rule, registered under their own lumen: scheme rather than borrowed from Ghost's ghost:. A game imports whatever it uses, from whichever scheme it lives under.
import "ghost:math" // Ghost's own standard library
import "lumen:canvas" // the whole module, bound to `canvas`
import "lumen:canvas" as gfx // aliased
import { setColor, print } from "lumen:canvas" // named imports
Lumen registers these modules:
| Module | Import | For |
|---|---|---|
canvas | lumen:canvas | Drawing, draw state, and the transform stack |
color | lumen:color | Building colors |
image | lumen:image | Images, spritesheets, and animations |
font | lumen:font | Loading and measuring fonts |
audio | lumen:audio | Sound effects and music |
keyboard | lumen:keyboard | Key state and text input |
mouse | lumen:mouse | Pointer position, buttons, and cursor |
joystick | lumen:joystick | Game controllers |
window | lumen:window | Window size, mode, and the logical canvas |
timer | lumen:timer | Frame timing and FPS |
filesystem | lumen:filesystem | Saved games and shipped data files |
system | lumen:system | Clipboard, power, and the engine itself |
lumen | lumen:lumen | Quitting, and the target frame rate |
Maths comes from Ghost's own math, imported as ghost:math. See Math for games for the parts a game reaches for most.
Classes
Image, Spritesheet, Animation, Source, Font, Target, and Quad are native classes: built and driven by Lumen, and new-ed exactly like a class declared in Ghost once imported from the module that exports them.
import { Image } from "lumen:image"
sprite = new Image('resources/player.png')
Needing both a module and one of its classes doesn't take two lines — the two forms combine, module name first and the braced list after it:
import audio, { Source } from "lumen:audio"
audio.setVolume(0.5)
sound = new Source('resources/hit.wav')
| Class | Import from |
|---|---|
Image, Spritesheet, Animation | lumen:image |
Source | lumen:audio |
Font | lumen:font |
Target, Quad | lumen:canvas |
Writing Ghost for Lumen
A few of Ghost's rules surprise people coming from other languages, and every one of them bites hardest inside a game loop.
Assignment is local to the function it happens in. A bare score = score + 1 inside update() creates a new local each frame; the outer score never changes. Keep mutable state on a map or a class instance, where assignment goes through a property and mutates in place.
game = { score: 0 }
function update(dt) {
game.score = game.score + 1 // works
}
and and or evaluate both sides. They do not short-circuit, so a guard cannot protect the test beside it:
if (j >= 0 and list[j].y > 0) { } // list[j] is read even when j is -1
Split it into nested ifs instead. A negative or out-of-range list index reads as null rather than raising, so the failure shows up later as an error about a property of null.
An imported name shadows anything else you'd call it. If a file imports "lumen:font", naming a variable or parameter font in that file hides the module for the rest of it. The same goes for a named class import — a file that imports { Image } cannot also use Image as a variable name. Name them bodyFont, sprite, and so on. It only matters in the file that did the importing, since imports are not global.
default is a keyword. It cannot be used as a method name, which is why the built-in font is font.system(size) rather than font.default(size).
When Something Goes Wrong
A game runs in its own window, and often on a machine nobody started it from a terminal on. So every failure is reported twice: once to the console, and once into the window, where whoever is looking at the game will actually see it.
Both reports say the same things in the same order, and they are Ghost's own error reports — what sort of failure it is, what happened, where, the line it happened on with the offending part marked, what was in flight at the time, and what to do about it.
argument error: `canvas.print()` expects argument 1 to be a string, got number
--> main.gs:12:5
|
12 | canvas.print(score, 10, 10)
| ^^^^^^^^^^^^
|
= in drawScore(), called at main.gs:30:3
= in draw()
= help: did you mean `text(score)`?
The window shows that report on an error screen, and the game holds still on it:
| Key | What it does |
|---|---|
esc or q | close the game |
enter or space | carry on, if carrying on is possible |
c | copy the report to the clipboard |
Carrying on is offered for a failure inside update(), draw(), or an input callback — one bad frame is worth watching past. It is not offered for a failure in load() or in the game's source, because neither ever built the state every later frame reads. A failure carried on past is not stopped for again: it is counted in the console instead, so the same broken frame cannot bury everything above it.
While the error screen is up, no game code runs at all — not update(), not draw(), and not the input callbacks — so the keys above always belong to the error screen, even in a game that has bound them.
Where Lumen is holding the answer, it offers it. A misspelled asset name is answered with the file sitting next to it, and a misremembered mode, key, button, or axis name with the nearest real one:
system error: `Image()` could not load `playr.png`: No such file or directory
--> main.gs:2:20
|
2 | player = new Image("playr.png")
| ^^^^
|
= in load()
= help: did you mean `player.png`?
Running without a window
A game run headlessly — in CI, in a build script, over ssh — has a window nobody can see and no way to dismiss what is on it, so Lumen writes the report to the console and stops rather than holding open a window nothing will ever close. A run that ended on a failure exits non-zero.
Console reports are colored when the terminal can show it, and follow the usual switches: NO_COLOR, CLICOLOR=0, FORCE_COLOR, CLICOLOR_FORCE.
Set LUMEN_DEBUG=1 to attach the Go stack to an internal error. It is only worth doing when filing a bug: it says nothing about the game, and everything about where Lumen broke.
Examples
The repository ships with sixteen examples. Run one with make run EXAMPLE=<name> or lumen examples/<name>.
| Example | Shows |
|---|---|
01_draw | shapes and colors |
02_input | reading the keyboard |
03_modular | splitting a game across files |
04_collision | rectangle overlap |
05_translate | moving the world under a camera |
06_spritesheets | slicing an image into tiles |
07_animations | frame animation |
08_tilemap | drawing a grid of tiles |
11_camera | following a player |
12_mouse | pointer position and buttons |
13_mouse_select | click-and-drag selection |
50_conway | Conway's Game of Life |
51_player_animations | directional walk cycles |
52_tiled_maps | loading a map exported from Tiled |
53_top_down | a tiled world with a following camera |
60_rpg | a complete top-down RPG with turn-based battles |
60_rpg is the one to read first if you are building something. It has a Tiled map with per-layer collision and view culling, a smoothed camera with bounds and screen shake, dt-driven walk animations, depth-sorted characters, NPCs and typewriter dialogue, weighted random encounters, front-view turn-based battles with spells and items, levelling, a party with equipment and a shared pack, scrolling menus, saving and loading, sound, and gamepad support.
What Lumen Doesn't Have
An honest list, so you find out here rather than halfway through a project:
- Cross-compiling. Lumen links SDL2 through cgo, so
lumen buildproduces a binary for the machine that ran it, and only that. Shipping to three platforms means building on three platforms. - Shaders and particles. Neither exists. Particles can be written in Ghost; shaders cannot be worked around, which rules out lighting, palette swaps, and whole-screen effects.
- Physics. There is no Box2D equivalent. Axis-aligned collision is a few lines of Ghost, which covers a top-down RPG and most puzzle games, and nothing that needs slopes, joints, or stacking.
- Asset hot-reloading. Changing a sprite or a line of dialogue means restarting the game.
- Ghost-side gaps. No string slicing, no removal of a list element by index, and no way for a host program to expose properties on its own objects — so everything Lumen hands back is a method call.
How Lumen Is Shaped
A handful of decisions run through the whole engine. Knowing them up front explains most of what the reference pages say.
- Drawing methods live on the thing being drawn.
sprite.draw(x, y), not a draw call on the graphics module that takes the sprite as an argument. It is the shape the rest of Ghost already has. - The renderer is
canvas, and an off-screen surface is aTarget. - Color channels run 0-255 and alpha runs 0-1. The two ranges are deliberately different, so that
1and1.0can never be confused for one another. - The draw state resets every frame. Color, line width, transform, blend mode, and scissor all go back to their defaults before
draw()runs, so a frame is self-contained. The current font is the one exception. - Batching is automatic. There is no batch object to fill: consecutive draws sharing a texture are collected into one call as they are made, and off-screen sprites are dropped before they reach SDL. See Drawing performance.
- Spritesheets and animations ship with the engine, as native classes, rather than being left to a package ecosystem Lumen does not have.