The built-in pieces are modules implementing Ascii.Piece, and yours can be too. A piece module works everywhere a slug does: Ascii.new/2, Ascii.render/3, Ascii.stream/2, Ascii.Player, the renderers and the ascii/1 component.

The contract

A piece has three functions:

  • meta/0 returns an Ascii.Meta: its name, what you see, its size in cells, its frame rate, the default of every option, and, for a coloured piece, its palette (and a ground, for a scene).
  • init/1 receives the options (the defaults merged with what was asked for, already checked) and returns a state. Precompute here.
  • frame/3 receives the state, the play time t in seconds and an Ascii.Env, and returns {text, colors, state}.

A first piece

defmodule MyApp.Pieces.Wave do
  @behaviour Ascii.Piece

  @cols 48
  @rows 9

  @impl true
  def meta do
    %Ascii.Meta{
      name: "wave",
      category: :generative,
      note: "a sine wave rolling by",
      cols: @cols,
      rows: @rows,
      fps: 20,
      options: %{speed: 1.0, crest: "~"},
      bounds: %{speed: {0.1, 10}},
      palette: ["#1d4e89", "#7fd1ff"]
    }
  end

  @impl true
  def init(%{speed: speed, crest: crest}) do
    # Options are checked against the defaults, so crest is a string; keep its
    # first character, so every line stays @cols wide.
    %{speed: speed, crest: crest |> String.codepoints() |> List.first("~")}
  end

  @impl true
  def frame(%{speed: speed, crest: crest} = state, t, env) do
    heights = for x <- 0..(@cols - 1), do: round((@rows - 1) * (1 + :math.sin(x / 5 + t * speed)) / 2)

    text =
      Enum.map_join(0..(@rows - 1), "\n", fn y ->
        for h <- heights, into: "", do: if(h == y, do: crest, else: " ")
      end)

    colors =
      if env.color?,
        do: for(y <- 0..(@rows - 1), _h <- heights, into: <<>>, do: <<if(y < @rows / 2, do: 1, else: 0)>>)

    {text, colors, state}
  end
end

{:ok, anim} = Ascii.new(MyApp.Pieces.Wave, options: %{"speed" => "3", "crest" => "≈"})
{frame, _anim} = Ascii.frame(anim, 1.0)
{length(frame.lines), frame.lines |> Enum.map(&String.length/1) |> Enum.uniq()}
#=> {9, [48]}

Options come in through Ascii.Options like a built-in piece's: only the keys in meta.options, of the same kind as their defaults, and numbers clamped to meta.bounds (or ±1,000,000 without one). Still, init/1 should make any value it is given safe to draw, as crest is here.

Rules every piece keeps

  • Exact size. The text is rows lines of exactly cols characters (code points), joined by "\n", with no newline at the end. Every renderer relies on it. text may be iodata.
  • Colours only when asked. Return nil colours unless the piece has a palette and env.color? is true; then a binary of cols * rows bytes, row by row, each an index into the palette. With env.color? false a coloured piece draws its one-ink version, as text.
  • Paper. On a light page (env.paper) dense characters must still mean dark, so a shaded piece reads its ramp from the other end: ramp = if env.paper, do: String.reverse(@ramp), else: @ramp.
  • A function of its inputs. A frame depends only on the state, t and the env: no processes, no process dictionary, no wall clock. Pieces that show the time set clock: true in their meta and read env.now (nil means the system clock). Random pieces draw from a seeded generator kept in the state (or seeded from the options), so the same calls give the same frames.
  • Any t. t usually grows by 1/fps, but a player can pause, skip, or start over. A piece that steps a simulation should step by how far t moved, cap a long jump, and cope with t going backwards.
  • Cheap frames. Do the expensive work in init/1. Upstream's budget is 4 ms a frame for text pieces, and 10 ms for 200×100 scenes.

The built-in pieces use :math too, and Ascii.JSMath only where JavaScript's numbers behave differently from Elixir's (Math.round rounds halves up, % keeps the dividend's sign), to match upstream. Your own piece has no upstream to match, so it needs neither.

Options from outside, a size, and live data

A few more things a piece can say, all optional:

  • bounds in its meta: the range a numeric option is clamped to when it comes from outside, such as %{speed: {0.1, 10}}.
  • shapes in its meta: for an option whose default cannot show what it takes, an example of what it does. An empty list takes strings unless told otherwise, so a list of numbers says %{values: [0.0]}.
  • size/1: the frame's size for the options, {cols, rows}, for a piece that fits its data or a width. Every frame must be that size.
  • update/2: the state for new options while it plays (see Ascii.put_options/2), keeping what it can, so a needle swings on from where it was. Without it, init/1 starts afresh.
defmodule MyApp.Pieces.Level do
  @behaviour Ascii.Piece

  @impl true
  def meta do
    %Ascii.Meta{
      name: "level",
      category: :data,
      note: "a bar that eases to its value",
      cols: 20,
      rows: 1,
      fps: 20,
      options: %{value: 0, width: 20},
      bounds: %{value: {0, 100}, width: {4, 200}}
    }
  end

  @impl true
  def size(%{width: width}), do: {trunc(width), 1}

  @impl true
  def init(%{value: value} = options), do: Map.merge(options, %{shown: value * 1.0, at: nil})

  # A new value: ease to it from what was shown, when it was shown.
  @impl true
  def update(state, options), do: options |> init() |> Map.merge(Map.take(state, [:shown, :at]))

  # Eases toward the value by how far play time moved since the last frame.
  @impl true
  def frame(%{value: value, shown: shown, width: width} = state, t, _env) do
    step = min(1.0, max(0.0, t - (state.at || t)) * 6)
    shown = shown + (value - shown) * step
    full = round(trunc(width) * shown / 100)
    text = [String.duplicate("█", full), String.duplicate("░", trunc(width) - full)]
    {text, nil, %{state | shown: shown, at: t}}
  end
end

anim = Ascii.new!(MyApp.Pieces.Level, options: %{value: 100, width: 10})
{frame, anim} = Ascii.frame(anim, 0.0)
frame.text
#=> "██████████"

anim = Ascii.put_options!(anim, value: 0)
{frame, _anim} = Ascii.frame(anim, 0.05)
frame.text
#=> "███████░░░"

Testing it

Check the contract over a run of times, in colour and not, on paper and not, including a step backwards:

# needs: your test suite
defmodule MyApp.Pieces.WaveTest do
  use ExUnit.Case, async: true

  for color <- [true, false], paper <- [false, true] do
    test "frames fit the meta (color: #{color}, paper: #{paper})" do
      {:ok, anim} = Ascii.new(MyApp.Pieces.Wave, color: unquote(color), paper: unquote(paper))
      meta = anim.meta

      Enum.reduce([0.0, 0.5, 1.0, 2.5, 10.0, 1.0], anim, fn t, anim ->
        {frame, anim} = Ascii.frame(anim, t)
        assert length(frame.lines) == meta.rows
        assert Enum.all?(frame.lines, &(length(String.codepoints(&1)) == meta.cols))
        if unquote(color), do: assert(byte_size(frame.colors) == meta.cols * meta.rows), else: assert(frame.colors == nil)
        anim
      end)
    end
  end
end

Using it

alias MyApp.Pieces.Wave

Ascii.render!(Wave, 0.5) |> String.split("\n") |> length()
#=> 9

Ascii.stream!(Wave) |> Enum.take(3) |> Enum.map(& &1.t)
#=> [0.0, 0.05, 0.1]

Pass the module wherever a slug goes, in a template:

<Ascii.Phoenix.Components.ascii piece={MyApp.Pieces.Wave} />
<Ascii.Phoenix.player socket={@socket} id="wave" piece={MyApp.Pieces.Wave} />

or by name to the mix task, which looks for it among the modules of your project and its dependencies:

mix ascii.play MyApp.Pieces.Wave --option speed=3

A piece module reaches the player's LiveView in its session, which only your code writes. A string there, as from params, is always a slug: nothing from outside can name a module.