website is under construction
Lumen

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
FlagDescription
-hShow help.
-vShow the version and exit.
-oOutput 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 game map 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-onlyconsole 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:

ModuleImportFor
canvaslumen:canvasDrawing, draw state, and the transform stack
colorlumen:colorBuilding colors
imagelumen:imageImages, spritesheets, and animations
fontlumen:fontLoading and measuring fonts
audiolumen:audioSound effects and music
keyboardlumen:keyboardKey state and text input
mouselumen:mousePointer position, buttons, and cursor
joysticklumen:joystickGame controllers
windowlumen:windowWindow size, mode, and the logical canvas
timerlumen:timerFrame timing and FPS
filesystemlumen:filesystemSaved games and shipped data files
systemlumen:systemClipboard, power, and the engine itself
lumenlumen:lumenQuitting, 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')
ClassImport from
Image, Spritesheet, Animationlumen:image
Sourcelumen:audio
Fontlumen:font
Target, Quadlumen: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:

KeyWhat it does
esc or qclose the game
enter or spacecarry on, if carrying on is possible
ccopy 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>.

ExampleShows
01_drawshapes and colors
02_inputreading the keyboard
03_modularsplitting a game across files
04_collisionrectangle overlap
05_translatemoving the world under a camera
06_spritesheetsslicing an image into tiles
07_animationsframe animation
08_tilemapdrawing a grid of tiles
11_camerafollowing a player
12_mousepointer position and buttons
13_mouse_selectclick-and-drag selection
50_conwayConway's Game of Life
51_player_animationsdirectional walk cycles
52_tiled_mapsloading a map exported from Tiled
53_top_downa tiled world with a following camera
60_rpga 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 build produces 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 a Target.
  • Color channels run 0-255 and alpha runs 0-1. The two ranges are deliberately different, so that 1 and 1.0 can 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.