A 2D game engine for the BEAM: terminal first, with the browser and the desktop as front ends. A game is a value and a few pure functions; the engine draws it, runs it for many players, plays its sound and puts it on screen wherever you point it.

Getting started

def deps do
  [
    {:cauldron_2d, "~> 0.1"},
    {:cauldron_2d_drafter, "~> 0.1"},
    {:tuning_fork_speaker, "~> 0.1"}
  ]
end

Then read Your first game: it builds a small game from nothing — the game as a value, its tests, its art from .pic files, the terminal front end with settings and a hud, sound and music, a second player and a robot, the browser and the window — and every step is a working example in the repository.

The pieces, and where they live:

packagewhat
cauldron_2dthis package: the engine — tiles, sprites, camera, physics, input, worlds, players, replay, audio, the client contract
cauldron_2d_drafterthe terminal: a surface widget and a ready-made client
cauldron_2d_netthe network: the served side (a Phoenix channel, socket, pages and API) with cauldron.js for the browser, and the links an Elixir client uses to join a world over the socket or on a connected node — the wire, the remote calls, the load test
cauldron_2d_wxthe desktop: a wx window drawing the same wire
linocutpictures as text or code, and pictures derived from others
cauldron_2d_easel, _drafter, _web, _kinothe atlas editors: in the terminal, in a browser, in Livebook

What is in the engine

Cauldron2D.Atlasthe catalogue: a sprite (or frames at a rate), a glyph and a colour per tile, built in code or loaded from a directory of .png and .pic files; subscribe/1 tells of a change
Cauldron2D.Sheetthe atlas packed onto one raster with an index, for a browser or a window
Cauldron2D.Surfacea grid of cells composited into one raster, without touching a pixel per frame; movers, animated frames at a time, and labels in Linocut.Font
Cauldron2D.Camerawhich part of the world is on screen, and what is in each cell; Camera.Follow trails a target and shakes
Cauldron2D.Loopa fixed timestep, so game speed does not follow frame rate
Cauldron2D.Rendererpixels or glyphs, decided by the terminal
Cauldron2D.Rnga seeded generator carried in state, so a game is reproducible
Cauldron2D.Bodya point mass with a heading: thrust, drag, gravity fields, wrapping
Cauldron2D.Collisionswept bodies against a grid of whole and half tiles, and each other
Cauldron2D.Particlesshort-lived movers under a cap
Cauldron2D.Mapa tile map as text, picture or classic header format, with a game-supplied legend
Cauldron2D.Inputheld actions from key presses and releases, with tap and toggle fallbacks
Cauldron2D.Worlda Cauldron2D.Game run at a fixed tick for many players, each sent their own view; stats/1 says how it keeps up
Cauldron2D.World.Presence, Cauldron2D.World.Eventsjoins and leaves in every world, and a world's events each tick, for processes that are not players
Cauldron2D.Replaya record of joins and input that rebuilds the same game; Replay.Viewer plays it back to a watcher
Cauldron2D.Audioeffects placed around a listener, and music in sections and layers, per player
Cauldron2D.Client.Gamewhat a game gives every front end — terminal, browser, window — to put its worlds in front of a player; Client.Hud is the hud's data
Cauldron2D.Arenasthe arenas a game offers: a world each with its children, started on first use and swept when idle; Stats says how the node keeps up
Cauldron2D.Robota computer player: a process that joins as {:robot, n} and decides through a brain module on a cadence set by its skill
Cauldron2D.Grid.Path, Cauldron2D.Grid.Coarse, Cauldron2D.Grid.Fovways across a grid, in blocks, and what can be seen
Cauldron2D.Minimapthe whole map in a few rows of braille with marks
Cauldron2D.Ledger, Cauldron2D.Save, Cauldron2D.Pathsresults and boards, saved games, and where a game's files live
Cauldron2D.Beacona server found on the local network
Cauldron2D.Testa game driven in a test with no world and no clock
Cauldron2D.Tuning, Cauldron2D.Tracethe numbers a game plays by, and a trace mix cauldron.report reads

Putting it on screen is cauldron_2d_drafter, a separate package: nothing here depends on a UI framework, so a frame can be composed and asserted on as bytes with no terminal, no app loop and no timing involved.

How it fits together

A game is two modules. Cauldron2D.Game is the game itself — init/1, join/3, leave/2, handle_input/3, step/2, view/2, drain_events/1, all pure — and Cauldron2D.World runs it: players join from their own processes with Cauldron2D.Player, send what they hold, and receive {:cauldron_frame, %{tick, view, events}} every tick. Started with record: true the world keeps a Cauldron2D.Replay; with tick: :on_input it steps only when input arrives, which is the world for a turn-based game; pause/1 and resume/1 hold a clocked one; state: starts it from a saved game.

Cauldron2D.Client.Game is what the front ends need to show it — its title, atlas, actions and keymaps, arenas, how a view becomes a scene and a hud, its sounds and music — and cauldron_2d_drafter, cauldron_2d_net and cauldron_2d_wx each put it in front of a player with a title, a lobby, settings, the arena, a summary and the guide. An arena whose world is {module, opts} is the player's own, started by the client. The lobby lists the arenas; a game with only one and lobby?: false goes from its title straight into it. Neither has anything to do with how a world steps: a turn-based game whose players share a world has a lobby like any other, and what a player is in that world — a character, a party — is the game's to ask, through its join props or a page of its own.

Cauldron2D.Audio is one process per listener. Events carry a position and are scaled and panned by their distance from the listener. Cauldron2D.Audio.Music plays a piece the game declares as sections and layers — the engine names none of them — switching sections on bar boundaries with a cross-fade and layer gains at once.

Design notes

The engine draws; the game is the game

Entities, combat, generation, turn order and saving belong to the game. The engine's job is narrower: a grid of tile ids becomes bytes on a display, cheaply, on whatever protocol is there. The one place they meet is a function:

Cauldron2D.Camera.view(
  focus: {x, y},
  bounds: {width, height},
  size: {columns, rows},
  cell: fn {x, y} -> cell end
)

A roguelike's cell consults its map, entities and field of view; a brick game's consults a map of bricks. Neither tells the engine anything else about itself.

The fast path

Drawing every tile into a shared raster would be about 245,000 map writes a frame at 16 px tiles and a 40×24 viewport. Cauldron2D.Surface touches no pixel on the frame path:

  • a tile is composited once. A cell's appearance is fully described by {base, overlays, tint}; each distinct combination is alpha-blended once into a flat RGBA binary and cached in ETS. A few hundred cover a whole game
  • a frame is built by slicing. Pixel row n of a row of tiles is binary_part/3 of each tile at the same offset, concatenated — sub-binaries, no copying, no arithmetic per pixel

A 40×24 viewport is ~15,000 binary_part calls, about 1 ms, and the result goes straight into FrenchCurve.Raster.from_rgba/4.

Movers — sprites at fractional positions, for anything that is not on the grid — are a separate path that touches only the rows they land on, so a frame with none pays nothing for them.

Two cells per tile

A tile is two terminal cells wide and one tall. Cells are about twice as tall as they are wide, so a square tile drawn across two of them stays square — and the image's aspect ratio then matches the cell box the terminal scales it into, which keeps the art from being stretched by the terminal.

Text mode

Terminals with no pixel protocol get two-cell coloured glyphs, from the same cells, so the two renderings cannot disagree. Braille is not used for tiles: it packs 2×4 dots into a cell, so a 16-pixel tile would land on four dots and a monster would look like a wall.

Sub-tile positions have no text equivalent — a mover lands in the cell it is nearest to. A game that needs the fraction to be visible needs pixels.

Music

A layer written as a Strudel chain is performed live on the listener's stage, the section's chains stacked, so a cue sounds at the next bar with nothing rendered ahead. The player's music level is the pattern's master gain on the stage, so a change reaches notes already sounding. A layer given as a score, a function or PCM ({:pcm, binary}) is rendered once and cached instead, two stems at a time, the cued section first.

Built with it

  • Scriber — a turn-based roguelike with a console inside it
  • Carom — a real-time Arkanoid
  • ExPilot — a multiplayer XPilot over ssh, in a browser and in a window

License

MIT