Cauldron2D

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:

package what
cauldron_2d this package: the engine — tiles, sprites, camera, physics, input, worlds, players, replay, audio, the client contract
cauldron_2d_drafter the terminal: a surface widget and a ready-made client
cauldron_2d_net the 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_wx the desktop: a wx window drawing the same wire
linocut pictures as text or code, and pictures derived from others
cauldron_2d_easel, _drafter, _web, _kino the atlas editors: in the terminal, in a browser, in Livebook

What is in the engine

Cauldron2D.Atlas the 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.Sheet the atlas packed onto one raster with an index, for a browser or a window
Cauldron2D.Surface a 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.Camera which part of the world is on screen, and what is in each cell; Camera.Follow trails a target and shakes
Cauldron2D.Loop a fixed timestep, so game speed does not follow frame rate
Cauldron2D.Renderer pixels or glyphs, decided by the terminal
Cauldron2D.Rng a seeded generator carried in state, so a game is reproducible
Cauldron2D.Body a point mass with a heading: thrust, drag, gravity fields, wrapping
Cauldron2D.Collision swept bodies against a grid of whole and half tiles, and each other
Cauldron2D.Particles short-lived movers under a cap
Cauldron2D.Map a tile map as text, picture or classic header format, with a game-supplied legend
Cauldron2D.Input held actions from key presses and releases, with tap and toggle fallbacks
Cauldron2D.World a 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.Events joins and leaves in every world, and a world's events each tick, for processes that are not players
Cauldron2D.Replay a record of joins and input that rebuilds the same game; Replay.Viewer plays it back to a watcher
Cauldron2D.Audio effects placed around a listener, and music in sections and layers, per player
Cauldron2D.Client.Game what 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.Arenas the 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.Robot a 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.Fov ways across a grid, in blocks, and what can be seen
Cauldron2D.Minimap the whole map in a few rows of braille with marks
Cauldron2D.Ledger, Cauldron2D.Save, Cauldron2D.Paths results and boards, saved games, and where a game's files live
Cauldron2D.Beacon a server found on the local network
Cauldron2D.Test a game driven in a test with no world and no clock
Cauldron2D.Tuning, Cauldron2D.Trace the 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 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. Measured on a full playfield: 565 µs for the grid, 609 µs with four movers on top.

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

License

MIT