---
title: 'Art and graphics screens'
url: 'https://wiki.teletypegames.org/engines/rubbs/art'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/art.md'
lang: en
date: '2026-11-01'
description: "The pieces of a picture-led board, such as the TTG Clubhouse look of the Teletype BBS: drawings kept as readable YAML and drawn in 256-colour ANSI on the modern face and as raw PETSCII on the Commodore 64 face; a cursor menu on the C64 picture; widgets whose hotspots show the board's life; and the…"
taxonomy:
  tag:
    - reference
---

# Art and graphics screens

> The pieces of a picture-led board, such as the TTG Clubhouse look of the Teletype BBS: drawings kept as readable YAML and drawn in 256-colour ANSI on the modern face and as raw PETSCII on the [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore) face; a cursor menu on the C64 picture; widgets whose hotspots show the board's life; and the time of day and the season for choosing a picture's layers. Available since 0.30.0; every class is in the **[API reference](https://wiki.teletypegames.org/rdoc/bbs/latest/)**.

| Piece | What it does |
|---|---|
| `BBS::Petscii::Canvas` | The C64 screen as 40×25 cells in the uppercase/graphics set, out as raw PETSCII: a whole frame, then patches |
| `BBS::Art` | A YAML drawing: chars, colours, reverse, backgrounds, hotspots, layers; out to ANSI, a FrameBuffer or a Canvas |
| `BBS::Petscii::CanvasMenu` | `BBS::Selector`'s keys and items, drawn on a Canvas |
| `BBS::Widgets::Scene` | An Art as a modern-face widget, its hotspots filled from data |
| `BBS::Season` | Dawn, day, dusk or night by the sun; snow, pumpkin, fireworks, bunting |
| `art-preview` | Checks a drawing and shows it: ANSI, PETSCII bytes, a C64 screenshot |

# The C64 canvas

The C64 has two character sets. The lowercase set, which `session.petscii!` and `BBS::Petscii::Screen` speak, has letters of both cases but no graphics. The uppercase set has the card suits, balls, rounded corners, diagonals and quarter blocks, so a picture is drawn there. `BBS::Petscii::Canvas` holds the screen as cells and writes raw PETSCII, which goes out with `session.write_raw`, past the lowercase encoder:

```ruby
C = BBS::Petscii::Canvas
canvas = C.new
canvas.text(0, 1, 'TTG CLUBHOUSE', :yellow)
canvas.text(2, 1, '╭──╮', :light_blue)
canvas.text(3, 1, '█▀▀█', :orange)                 # reversed glyphs work too
session.write_raw(canvas.frame)                    # the whole screen

before = canvas.dup
canvas.text(2, 2, '●', :red)
session.write_raw(canvas.update(before))           # a patch: only the cell that changed
```

- Cells are drawn from Unicode: letters (shown as capitals), digits and signs as they are, the graphics by what they look like (`Canvas::GLYPHS`); `█ ▀ ▐ ◢ ◣` are reversed codes (`GLYPHS_REVERSED`). `Canvas.glyph?(ch)` tells whether the set can show a character.
- Colours are the codes (`Canvas::YELLOW`), names (`:light_blue`, `'dark grey'`) or, since 1.0.0, colours of the palette: `Canvas.color(YE) == Canvas::YELLOW`, so the same `BBS::Petscii::Colors` constants serve a Screen and a canvas.
- Since 1.0.0 there is one PETSCII glyph table: the rounded corners `╭╮╰╯` are `Petscii::ROUNDED_CORNERS` in this uppercase set, and the quarter blocks `▖▗▘▝▚` are drawn too; the lowercase set of a Screen shows the corners square.
- `frame` sends the screen once, each row cut after its last visible cell; `span(row, col, width)` one stretch of a row; `patch(positions)` scattered cells, the cursor stepping to the nearest; `update(before)` the cheaper of a patch and a frame. On a slow line, aim for about 1.2 KB a screen and under 200 bytes an animation step.
- `feed(bytes)` plays PETSCII onto a canvas as the C64's screen editor prints it: in a test, what the caller sees after your bytes (`canvas.changes(seen)` is empty when they agree).

The canvas came from TTG Gate BBS; its bytes are the same as the gate's own class gave.

# Drawings as YAML

A drawing is text, written and reviewed like code. `chars` is the picture, `colors` a palette letter a cell (`.` = no colour), `reverse` optional `R`s, `background` optional (cp437 only: the second colour of a half block), `hotspots` the cells the board fills from its data, `layers` other files stacked on it. Every row is exactly `size[0]` wide, trailing spaces too. Write the grids with `|2`: without the indentation indicator YAML takes the indentation from the first row, and a row that starts with spaces breaks the block.

A PETSCII drawing for the C64 face:

```yaml
# art/house.yml
size: [20, 6]
charset: petscii-upper     # only glyphs the uppercase set shows
palette:                   # C64 colour names, 256-colour indexes or '#rrggbb'
  y: yellow
  r: red
  b: brown
  g: dark grey
  w: white
chars: |2
       ◢████◣    ●    
      ◢██████◣        
      │ ▒  ▒ │        
      │ ▒  █ │  TTG   
  ▁▁▁▁│▁▁▁▁█▁│▁▁▁▁▁▁▁ 

colors: |2
  .....rrrrrr....y....
  ....rrrrrrrr........
  ....b.y..y.b........
  ....b.y..b.b..www...
  ggggbggggbgbggggggg.
  ....................
hotspots:
  window_1: [6, 2]         # [col, row], 0-based
  window_2: [9, 2]
  sign: [14, 3, 3, 1]      # [col, row, width, height]
layers: [snow]             # art/snow.yml
```

A layer: spaces are transparent, `at` places it, its palette and hotspots add to the drawing's.

```yaml
# art/snow.yml: a layer; spaces are transparent, 'at' places it
size: [10, 1]
at: [4, 0]
palette:
  w: white
chars: |2
   ▄▄▄▄▄▄   
colors: |2
  .wwwwww...
```

A cp437 drawing for the modern face, with backgrounds:

```yaml
size: [24, 4]
charset: cp437
palette: { y: yellow, o: 208, n: '#203060', w: white }
chars: |2
  ╔══════════════════════╗
  ║ ▄▀▀▄ TTG CLUBHOUSE ☼ ║
  ║ ▀▄▄▀  open tonight   ║
  ╚══════════════════════╝
colors: |2
  oooooooooooooooooooooooo
  oyyyyywwwwwwwwwwwwwwwyyo
  oyyyyyyyyyyyyyyyyyyyyyyo
  oooooooooooooooooooooooo
background: |2
  ........................
  .nnnnnnnnnnnnnnnnnnnnnn.
  .nnnnnnnnnnnnnnnnnnnnnn.
  ........................
hotspots:
  tonight: [8, 2, 13, 1]
```

Using them:

```ruby
house = BBS::Art.load('art/house.yml').with(*BBS::Season.now.layers)  # only the layers it lists
session.write(house.ansi)                        # modern: 256-colour ANSI lines
house.draw(frame, x: 1, y: 2)                    # … or into a BBS::FrameBuffer
session.write_raw(house.petscii)                 # C64: a whole frame
house.draw_canvas(canvas, row: 1, col: 10)       # … or onto a canvas
house.hotspot(:window_1)                         # => Art::Hotspot col=6 row=2 …
```

`with` stacks only the layers the file lists, so `with(*season.layers)` is safe for any drawing. A mistake raises `BBS::Art::Error` naming the file, the grid, the row and the column: a row of the wrong width, a letter not in the palette, a glyph the charset cannot show, a hotspot outside the drawing. Palette colours are C64 names, 256-colour indexes or `#rrggbb`; each is matched to the nearest colour of the other face, so a PETSCII drawing previews in ANSI and the reverse.

# A cursor menu on the canvas

`BBS::Petscii::CanvasMenu` is a `BBS::Selector` that draws on a canvas: the same items, keys and options (CRSR, RETURN, an item's key, RUN/STOP or ← to go back, numbers, columns, pages, the about and hint rows), the lit item a reversed bar in its own colour. Each move goes out as a patch of the changed cells: about 140 bytes in a 16-item, two-column menu.

```ruby
S = BBS::Selector
items = [S.item('CORK BOARD', key: 'm', value: :boards, color: :light_blue, about: 'READ AND POST'),
         S.item('GAMES ROOM', key: 'r', value: :doors, color: :orange),
         S.item('LEAVE',      key: 'q', value: :quit)]
menu = BBS::Petscii::CanvasMenu.new(session, items, canvas: canvas, row: 12, col: 1, width: 36,
                                    columns: 2, hint: 'CRSR: CHOOSE  RETURN: OPEN', idle: 0.5) do
  smoke.step(canvas)                 # bytes for an animation step while the caller thinks
end
menu.paint
session.write_raw(canvas.frame)      # the picture with the menu on it
choice = menu.run(drawn: true)       # without drawn: it paints and sends its rows itself
```

With `idle:` seconds and a block, the block is called while the caller thinks and returns the bytes of an animation step; a key stops waiting at once.

The menu's colour roles are the Selector's, `BBS::Selector::COLORS` (`text key bar heading about hint`), turned into C64 codes on the canvas; `colors:` overrides some of them. (`CanvasMenu::COLORS` is gone in 1.0.0.)

# Scenes on the modern face

`BBS::Widgets::Scene` is a drawing as a widget: the drawing, then each hotspot filled by its block at every render. A block returns a String (ANSI colours allowed, lines for a taller hotspot) or `nil` to leave the drawing; what it returns is cut to its hotspot.

```ruby
scene = BBS::Widgets::Scene.new(art: house, align: :center)
scene.fill(:window_1) { node_busy?(1) ? "\e[38;5;220m▓" : nil }
scene.fill(:sign)     { |spot| "\e[1;37m#{inside_count}".ljust(spot.width) }
```

On a terminal smaller than the drawing the scene is cut to its bounds; with `crop: false` it draws nothing, and `fits?` tells a dashboard whether to show the picture at all.

# Time of day and season

`BBS::Season.now(tz: 'Europe/Budapest', at: Time.now, jam: nil)` gives the time of day by the sun (`:dawn` and `:dusk` are the 45 minutes either side of sunrise and sunset, worked out for `latitude:` and `longitude:`, Budapest by default), the season, and the layers in effect:

| Layer | When |
|---|---|
| `:snow` | December to February |
| `:pumpkin` | 24–31 October |
| `:fireworks` | 31 December, and New Year's Day until 06:00 |
| `:bunting` | the days of `jam:`, a Range of Date |

```ruby
s = BBS::Season.now(jam: Date.new(2027, 1, 8)..Date.new(2027, 1, 10))
s.time_of_day          # => :night
s.layers               # => [:snow]
art = BBS::Art.load("art/house_#{s.night? ? 'night' : 'day'}.yml").with(*s.layers)
```

It is pure: a test passes `at:`. EU time zones and fixed offsets (`'+01:00'`) work without other gems; any other IANA name needs `tzinfo`.

# art-preview

An executable of the gem (`bundle exec art-preview`, or `bin/art-preview` in a rubbs checkout) for checking a drawing while drawing it:

```
art-preview art/house.yml                   # ANSI here, then size, palette, hotspots, PETSCII bytes
art-preview art/house.yml -l snow -s        # a layer stacked, the hotspots marked
art-preview art/house.yml --hex             # the PETSCII frame as a hex dump
art-preview art/house.yml --petscii h.seq   # the raw frame, e.g. for a C64 terminal program
art-preview art/house.yml --png h.png       # a C64 screenshot of the frame (VICE's character ROM)
```

`--png` plays the PETSCII frame onto a canvas and draws it with the C64 character ROM that VICE installs (`--chargen FILE` or `$C64_CHARGEN` for another); the ROM is not part of the gem. For a check in the emulator itself, load the `--petscii` file into a C64 terminal program, or watch the board in VICE.

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [RuBBS](https://wiki.teletypegames.org/engines/rubbs.md)
- Previous: [Application](https://wiki.teletypegames.org/engines/rubbs/application.md)
- Next: [Changelog](https://wiki.teletypegames.org/engines/rubbs/changelog.md)
