Create: Computing AgesBrass Docs
Libraries

gfx

Pixel drawing under the text of the screen: shapes, text, sprites and scrolling.

Transistor Mainframe and newer

The gfx library draws pixels on the computer's screen: points, lines, rectangles, circles, triangles, text in a small pixel font, sprites made of hex digits, and a scroll that slides the whole picture. It is how you build gauges, charts, maps of a rail network, control panels with real buttons, and games.

The drawing lies under the text of the screen. What you print stays readable on top of it, so a program can draw a picture and still use print, term.write and read as usual.

Brass
local s = gfx.size()
gfx.clear("light_blue")                              -- the sky
gfx.circle(262, 34, 16, "yellow", true)              -- the sun
gfx.rect(1, 128, s.w, s.h - 127, "green", true)      -- the grass
gfx.rect(104, 30, 12, 30, "gray", true)              -- a chimney
gfx.rect(36, 72, 96, 56, "brown", true)              -- the workshop
gfx.triangle(28, 72, 84, 38, 140, 72, "red", true)   -- its roof
gfx.rect(72, 98, 24, 30, "black", true)              -- the door
for i = 0, 7 do
  local a = i * math.pi / 4
  gfx.pixel(206 + math.cos(a) * 30, 96 + math.sin(a) * 30, "orange", 9)
end
gfx.circle(206, 96, 28, "orange", true)              -- a cogwheel
gfx.circle(206, 96, 9, "light_gray", true)
gfx.text(8, 8, "BRASS WORKS", "white", 2)
term.set_cursor(2, 18)
term.write("Text from term.write stays on top")
Screen
Screen
Functions
gfx.size()The size of the drawing in pixels: the size of the text grid times 6 by 9.
gfx.clear([color])Erases the drawing, or fills all of it with one colour.
gfx.get_pixel(x, y)The colour of one pixel of the drawing, as a number, even when it was drawn with a name. It is nil where nothing is drawn, outside the screen, and everywhere before the first drawing.
gfx.pixel(x, y, color [, size])Colours one pixel, or a square of size × size pixels centred on it.
gfx.line(x1, y1, x2, y2, color [, thickness])A straight line from (x1, y1) to (x2, y2), both ends included.
gfx.rect(x, y, w, h, color [, fill])A rectangle w pixels wide and h pixels tall, its top left corner at (x, y).
gfx.circle(x, y, radius, color [, fill])A circle around (x, y), or a disc, or a ring.
gfx.triangle(x1, y1, x2, y2, x3, y3, color [, fill])A triangle through three corners, given in any order.
gfx.fill(x, y, color)The paint bucket: repaints the whole area of the same colour as the pixel at (x, y).
gfx.text(x, y, text, color [, scale])Writes text with a tiny 3 × 5 pixel font, at any pixel and in any size. Returns the width it took.
gfx.image(x, y, rows [, scale [, flip]])Draws a sprite: a small picture written as text, one character per pixel.
gfx.scroll(dx, dy [, x, y, w, h])Slides the drawing by (dx, dy). What leaves the area is lost, and what comes in on the other side is empty.

The pixel layer

Every character cell of the screen is 6 pixels wide and 9 pixels tall, so the size of the drawing follows the size of the text grid:

ComputerTextPixelsColours
Tube Computer40 × 14no gfxpaper teletype
Transistor Mainframe51 × 19306 × 171green phosphor
Minicomputer51 × 19306 × 171amber phosphor
Personal Computer51 × 19306 × 17116 colours
Microcontroller40 × 12240 × 10816 colours
Modern Computer64 × 24384 × 21616 colours

The Tube Computer prints on paper and has no drawing: there, gfx is nil. A program meant to run everywhere can test if gfx then ... end.

Coordinates

Like term.set_cursor, coordinates start at (1, 1) in the top left corner. x grows to the right, y grows downwards. Decimal coordinates are rounded down, so you can pass the result of math.sin or a division directly.

Nothing is ever out of range: whatever falls outside the screen is simply cut off (clipped). A circle half off the edge draws its visible half, and a pixel at (-5, 400) draws nothing, without an error.

Colours

A colour is a name ("red"), or a number from 0 to 15 (term.colors.red, which is 14). The special name "none" (or the number -1) erases: the pixel becomes empty again and shows what is under the drawing. The 16 names and their numbers are in Colours. A number must be whole, from -1 to 15: anything else stops the program with bad argument #3 to 'pixel' (colour (0..15 or a name) expected, got number).

A name that does not exist stops the program, and the error lists the right ones:

Brass
gfx.rect(10, 10, 40, 20, "grey", true)
Screen
snippet:1: bad argument #5 to 'rect' (unknown colou
r 'grey': white, orange, magenta, light_blue, yello
w, lime, pink, gray, light_gray, cyan, purple, blue
, brown, green, red, black, none)

Layers: background, drawing, text

The screen is drawn in three layers, from bottom to top:

  1. the background colour of each character cell (term.set_bg then term.clear, or write);
  2. the drawing of gfx;
  3. the characters of the text.

So text always shows over the drawing, but the background colour of a character cell is hidden wherever something is drawn. Empty pixels let the cell backgrounds show through.

The drawing stays on the screen after the program ends. term.clear only clears the text: to remove the drawing, call gfx.clear(). The shell command clear, stopping a program with Ctrl+T and a reboot clear both.

Cost

Drawing is not free. Every call that changes pixels charges the program one instruction per 64 pixels touched, on top of the call itself. A Personal Computer runs 1,200 instructions per tick at full speed, and filling its whole screen (306 × 171 = 52,326 pixels) costs 817 of them. Text costs one instruction per character, times the scale squared; a sprite one per row, plus its pixels.

CallInstructions
gfx.pixel, gfx.line, gfx.rect, gfx.circle, gfx.triangle, gfx.fill, gfx.scroll, gfx.clear(color)1 per 64 pixels
gfx.textnumber of characters × scale²
gfx.image1 per row + 1 per 64 pixels
gfx.clear(), gfx.size, gfx.get_pixelnothing more than the call

The screen is sent to the players who look at it at most every 2 ticks (a server setting), and only the band of pixel rows that changed since the last update travels over the network: from the highest change to the lowest. A small animated gauge costs almost nothing; redrawing the whole screen every tick sends the whole picture every time. Draw only what changes (see the patterns at the end of this page).

Monochrome screens

The Transistor Mainframe (green) and the Minicomputer (amber) have a single phosphor colour. On them, every colour except black lights up in the phosphor colour, and black is the dark screen. A drawing meant for them uses black for "off" and any other colour for "on". (An LCD Monitor attached to them shows the real colours: see Screens and monitors.)

Brass
local names = {"white", "orange", "magenta", "light_blue", "yellow", "lime", "pink", "gray",
  "light_gray", "cyan", "purple", "blue", "brown", "green", "red", "black"}
gfx.clear("black")
gfx.text(8, 8, "THE 16 COLOURS", "white", 2)
for i = 1, 16 do
  local x = 8 + (i - 1) * 18
  gfx.rect(x, 40, 16, 80, names[i], true)
  gfx.text(x + 2, 126, tostring(i - 1), "white")
end
gfx.rect(277, 39, 18, 82, "white")   -- a frame around the black one
gfx.text(8, 150, "BLACK (15) IS THE ONLY DARK ONE", "white")
Screen
Screen

The same program on a Personal Computer:

Brass
local names = {"white", "orange", "magenta", "light_blue", "yellow", "lime", "pink", "gray",
  "light_gray", "cyan", "purple", "blue", "brown", "green", "red", "black"}
gfx.clear("black")
gfx.text(8, 8, "THE 16 COLOURS", "white", 2)
for i = 1, 16 do
  local x = 8 + (i - 1) * 18
  gfx.rect(x, 40, 16, 80, names[i], true)
  gfx.text(x + 2, 126, tostring(i - 1), "white")
end
gfx.rect(277, 39, 18, 82, "white")   -- a frame around the black one
gfx.text(8, 150, "BLACK (15) IS THE ONLY DARK ONE", "white")
Screen
Screen
#

gfx.size()

→ table

The size of the drawing in pixels: the size of the text grid times 6 by 9.

Returns
table
{w = width, h = height} in pixels
Brass
local s = gfx.size()
print(s.w, s.h)
local t = term.get_size()
print(t.w * 6, t.h * 9)
Screen
Screen

Use it instead of fixed numbers, and the same program fits every screen. This frame follows the edges of the screen whatever the computer:

Brass
local s = gfx.size()
gfx.clear("black")
gfx.line(1, 1, s.w, s.h, "gray")
gfx.line(s.w, 1, 1, s.h, "gray")
gfx.rect(1, 1, s.w, s.h, "yellow", 2)
gfx.text(5, 5, "1,1", "white")
local corner = s.w .. "," .. s.h
gfx.text(s.w - #corner * 4 - 3, s.h - 9, corner, "white")
Screen
Screen

The same code on a Microcontroller, whose screen is 240 × 108:

Brass
local s = gfx.size()
gfx.clear("black")
gfx.line(1, 1, s.w, s.h, "gray")
gfx.line(s.w, 1, 1, s.h, "gray")
gfx.rect(1, 1, s.w, s.h, "yellow", 2)
gfx.text(5, 5, "1,1", "white")
local corner = s.w .. "," .. s.h
gfx.text(s.w - #corner * 4 - 3, s.h - 9, corner, "white")
Screen
Screen

See also term.get_size()

#

gfx.clear([color])

⚙ cost 1 per 64 px

Erases the drawing, or fills all of it with one colour.

Parameters
color string|number optional
a colour that fills the whole picture; left out (or "none"), the drawing is erased

Without a colour, every pixel becomes empty and the cell backgrounds of the text show again. This costs nothing. With a colour, the whole picture is painted, which costs one instruction per 64 pixels (817 on a Personal Computer).

gfx.clear does not touch the text, and term.clear does not touch the drawing. Here the cells are made gray with term.set_bg, the drawing covers them in blue, and a rectangle of "none" opens a window where the gray shows again:

Brass
term.set_bg(term.colors.gray)
term.clear()                                -- the text grid: gray cells
gfx.clear("blue")                           -- the drawing covers them
gfx.rect(84, 50, 144, 72, "none", true)     -- erased pixels: the gray shows again
term.set_cursor(16, 9)
term.write("a hole in the drawing")
term.set_cursor(2, 2)
term.write("The text is always on top")
Screen
Screen

A program that redraws its screen from scratch usually starts with both:

Brass
term.set_bg(term.colors.black)
term.clear()
gfx.clear()

See also term.clear()

#

gfx.get_pixel(x, y)

→ number|nil

The colour of one pixel of the drawing, as a number, even when it was drawn with a name. It is nil where nothing is drawn, outside the screen, and everywhere before the first drawing.

Parameters
x number
column of the pixel, from 1
y number
row of the pixel, from 1
Returns
number|nil
the colour number (0 to 15), or nil where nothing is drawn
Brass
gfx.rect(10, 10, 20, 20, "red", true)
print(gfx.get_pixel(15, 15))
print(gfx.get_pixel(15, 15) == term.colors.red)
print(gfx.get_pixel(100, 100))
Screen
14
true
nil
Watch out

Compare with a number: gfx.get_pixel(x, y) == "red" is always false. Use term.colors.red or 14.

It only reads the drawing, never the text. It is handy in small games, to know what a moving piece is about to hit: a wall drawn in gray, a coin drawn in yellow.

Points and lines

#

gfx.pixel(x, y, color [, size])

⚙ cost 1 per 64 px

Colours one pixel, or a square of size × size pixels centred on it.

Parameters
x number
column, from 1
y number
row, from 1
color string|number
a colour name or number, "none" to erase
size number optional
the side of a square centred on (x, y), 1 to 64 (1 when left out)

With an odd size, (x, y) is exactly in the middle. With an even size, the extra row and column go to the right and to the bottom. Sizes above 64 are taken as 64. The red dots below mark the point given:

Brass
gfx.clear("black")
local sizes = {1, 2, 3, 5, 9, 15, 25}
local x = 16
for i = 1, #sizes do
  gfx.pixel(x, 60, "yellow", sizes[i])
  gfx.pixel(x, 60, "red")
  gfx.text(x - 2, 90, tostring(sizes[i]), "white")
  x = x + 22 + sizes[i]
end
gfx.text(8, 8, "GFX.PIXEL(X, Y, COLOR, SIZE)", "white", 2)
gfx.text(8, 130, "THE RED DOT IS THE POINT (X, Y)", "red")
Screen
Screen

Points are the right tool for plots: one reading, one dot. Here a day of a solar-like curve, one dot every 4 pixels:

Brass
local s = gfx.size()
gfx.clear("black")
gfx.line(1, 150, s.w, 150, "gray")
for x = 1, s.w, 4 do
  local y = 150 - math.max(0, math.sin((x - 20) / 270 * math.pi)) * 120
  gfx.pixel(x, y, "yellow", 3)
end
gfx.text(4, 158, "6:00", "white")
gfx.text(s.w - 22, 158, "18:00", "white")
Screen
Screen

See also gfx.rect() gfx.get_pixel()

#

gfx.line(x1, y1, x2, y2, color [, thickness])

⚙ cost 1 per 64 px

A straight line from (x1, y1) to (x2, y2), both ends included.

Parameters
x1 number
column of the start
y1 number
row of the start
x2 number
column of the end
y2 number
row of the end
color string|number
a colour name or number, "none" to erase
thickness number optional
1 to 64 pixels (1 when left out)

A thin line is one pixel wide and has no gaps, even when it climbs steeply. A thick line stamps a square of thickness pixels at every point, so its ends are square, and it costs more: a 100-pixel line 8 pixels thick touches 6,400 pixels.

Brass
gfx.clear("black")
for i = 1, 4 do
  local t = 2 ^ (i - 1)
  local y = 10 + i * 30
  gfx.line(16, y + 12, 136, y - 8, "orange", t)
  gfx.text(146, y, "THICKNESS " .. t, "white")
end
for degrees = 0, 330, 30 do
  local a = degrees * math.pi / 180
  gfx.line(254, 86, 254 + math.cos(a) * 44, 86 + math.sin(a) * 44, "yellow")
end
gfx.circle(254, 86, 10, "yellow", true)
Screen
Screen

Lines between the readings of a sensor make a chart. Each segment joins one value to the next:

Brass
local levels = {40, 52, 47, 70, 95, 88, 120, 112, 90, 64, 58, 75}
gfx.clear("black")
gfx.rect(10, 10, 286, 150, "gray")
for i = 1, #levels - 1 do
  local x1 = 12 + (i - 1) * 25
  local x2 = 12 + i * 25
  gfx.line(x1, 158 - levels[i], x2, 158 - levels[i + 1], "lime", 2)
end
gfx.text(16, 16, "TANK LEVEL, LAST 12 HOURS", "white")
Screen
Screen

See also gfx.pixel() gfx.triangle()

Shapes

The shapes take their size and colour, then an optional last argument that says how to draw them: left out, an outline one pixel wide; true, filled; for rectangles and circles, a number gives the thickness of the border.

#

gfx.rect(x, y, w, h, color [, fill])

⚙ cost 1 per 64 px

A rectangle w pixels wide and h pixels tall, its top left corner at (x, y).

Parameters
x number
column of the top left corner
y number
row of the top left corner
w number
width in pixels
h number
height in pixels
color string|number
a colour name or number, "none" to erase
fill boolean|number optional
true to fill it, or the thickness of the border (1 to 64); an outline 1 pixel wide when left out

The border is drawn inside the rectangle: gfx.rect(10, 10, 50, 20, "red", 3) stays within the same 50 × 20 pixels as the filled rectangle, with a hole in the middle. When the border is at least half of the smaller side, the rectangle is simply filled. A width or a height of zero or less draws nothing.

Brass
gfx.clear("black")
gfx.rect(10, 20, 86, 50, "white")
gfx.rect(110, 20, 86, 50, "lime", true)
gfx.rect(210, 20, 86, 50, "orange", 6)
gfx.text(10, 76, "OUTLINE", "white")
gfx.text(110, 76, "TRUE: FILLED", "white")
gfx.text(210, 76, "6: BORDER", "white")
-- a progress bar: a frame, then the filled part
local done = 0.7
gfx.rect(10, 110, 200, 22, "white")
gfx.rect(12, 112, math.floor(196 * done), 18, "green", true)
gfx.text(220, 116, math.round(done * 100) .. "% CRUSHED", "white")
Screen
Screen

Filled rectangles are also the fastest way to erase part of the picture before drawing it again: paint it with the background colour, or with "none".

See also gfx.pixel() gfx.clear()

#

gfx.circle(x, y, radius, color [, fill])

⚙ cost 1 per 64 px

A circle around (x, y), or a disc, or a ring.

Parameters
x number
column of the centre
y number
row of the centre
radius number
in pixels; 0 draws a single pixel
color string|number
a colour name or number, "none" to erase
fill boolean|number optional
true for a disc, or the thickness of the ring (1 to 64); an outline 1 pixel wide when left out

The circle is 2 × radius + 1 pixels across. A border thickness grows inwards, like the border of a rectangle. A negative radius draws nothing.

Brass
gfx.clear("black")
gfx.circle(40, 50, 28, "white")
gfx.circle(40, 120, 28, "light_blue", true)
gfx.circle(110, 85, 34, "orange", 6)
gfx.text(14, 160, "OUTLINE, DISC, RING", "white")
-- a Create cogwheel: teeth first, then the wheel over them
local cx, cy = 226, 85
for i = 0, 11 do
  local a = i * math.pi / 6
  gfx.pixel(cx + math.cos(a) * 52, cy + math.sin(a) * 52, "brown", 13)
end
gfx.circle(cx, cy, 50, "brown", true)
gfx.circle(cx, cy, 40, "orange", 3)
gfx.circle(cx, cy, 14, "light_gray", true)
gfx.circle(cx, cy, 5, "black", true)
Screen
Screen

With math.cos and math.sin, circles become lamps, dials and radar screens. A ring of 8 status lamps, lit by a bit pattern:

Brass
local lit = {true, true, false, true, false, false, true, true}
gfx.clear("black")
for i = 1, 8 do
  local a = (i - 1) * math.pi / 4 - math.pi / 2
  local x = 153 + math.cos(a) * 60
  local y = 86 + math.sin(a) * 60
  local color = "gray"
  if lit[i] then color = "lime" end
  gfx.circle(x, y, 12, color, true)
  gfx.circle(x, y, 12, "white")
  gfx.text(x - 1, y - 2, tostring(i), "black")
end
Screen
Screen

See also gfx.rect() math.cos()

#

gfx.triangle(x1, y1, x2, y2, x3, y3, color [, fill])

⚙ cost 1 per 64 px

A triangle through three corners, given in any order.

Parameters
x1 number
column of the first corner
y1 number
row of the first corner
x2 number
column of the second corner
y2 number
row of the second corner
x3 number
column of the third corner
y3 number
row of the third corner
color string|number
a colour name or number, "none" to erase
fill boolean optional
true to fill it; an outline 1 pixel wide otherwise

Unlike rectangles and circles, a triangle has no border thickness: the last argument is true for a filled triangle, anything else draws the outline. For a thick outline, draw three lines with a thickness.

Triangles make roofs, mountains, and above all arrows:

Brass
gfx.clear("black")
gfx.triangle(20, 80, 60, 16, 100, 80, "green", true)
gfx.triangle(120, 80, 160, 16, 200, 80, "white")
gfx.text(20, 88, "FILLED", "white")
gfx.text(120, 88, "OUTLINE", "white")
-- trend arrows next to two stocks
gfx.triangle(20, 140, 30, 124, 40, 140, "lime", true)
gfx.text(48, 130, "IRON  +120/H", "lime")
gfx.triangle(150, 124, 160, 140, 170, 124, "red", true)
gfx.text(178, 130, "COAL  -45/H", "red")
Screen
Screen

See also gfx.line()

#

gfx.fill(x, y, color)

⚙ cost 1 per 64 px

The paint bucket: repaints the whole area of the same colour as the pixel at (x, y).

Parameters
x number
column of the starting pixel
y number
row of the starting pixel
color string|number
the new colour, or "none" to erase the area

The area spreads from (x, y) to the neighbours above, below, left and right that have exactly the same colour as the starting pixel. It stops at any other colour. Outlines drawn by gfx.line, gfx.circle and gfx.triangle are closed for it, even where they go diagonally, so the inside of a shape fills without leaking.

Empty pixels count as a colour of their own: on a picture cleared with gfx.clear("black"), a hole erased with "none" is a wall for a fill that starts on black.

Nothing happens when the pixel already has the new colour, or when (x, y) is outside the screen. The fill stops after 1,048,576 pixels, far more than the largest screen (82,944 pixels on a Modern Computer), so in practice it always fills the whole area, at one instruction per 64 pixels.

A closed shape fills on the inside only. Open a gap of one pixel and the paint escapes:

Brass
gfx.clear("black")
gfx.rect(4, 20, 146, 120, "gray")
gfx.rect(156, 20, 146, 120, "gray")
gfx.circle(77, 80, 40, "white")
gfx.circle(229, 80, 40, "white")
gfx.rect(266, 76, 6, 9, "black", true)     -- a gap in the right circle
gfx.fill(77, 80, "orange")
gfx.fill(229, 80, "orange")
gfx.text(30, 148, "CLOSED", "white")
gfx.text(170, 148, "ONE GAP: IT LEAKS", "white")
Screen
Screen

The shapes example of the computer uses it for clicks: each click paints the area under the mouse (see shapes).

See also gfx.get_pixel()

Text and sprites

#

gfx.text(x, y, text, color [, scale])

→ number⚙ cost characters × scale²

Writes text with a tiny 3 × 5 pixel font, at any pixel and in any size. Returns the width it took.

Parameters
x number
column of the left edge of the first letter
y number
row of the top of the letters
text string
the text (a number is turned into text)
color string|number
a colour name or number, "none" to erase
scale number optional
1 to 64, the size of each dot of the font (1 when left out)
Returns
number
the width of the text drawn, in pixels

Each letter is 3 dots wide and 5 tall, with one empty column after it: 4 pixels per character, so a line of 76 characters fits a Personal Computer at scale 1. At scale 2, every dot becomes 2 × 2 pixels, and so on.

The font has capital letters only (lowercase is drawn in capitals), digits, and these signs: space . , : ; ! ? ' " - + = / ( ) < > % * _ # [ ]. Accents are removed (É is drawn E), and every other character becomes ?.

Brass
gfx.clear("black")
gfx.text(4, 4, "ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789", "white")
gfx.text(4, 12, ".,:;!?'\"-+=/()<>%*_#[]", "white")
gfx.text(4, 22, "lowercase is drawn in capitals", "yellow")
gfx.text(4, 30, "Déjà vu: accents are removed", "yellow")
gfx.text(4, 38, "No glyph: @ & $ { } ~", "red")
gfx.text(4, 54, "SCALE 2", "lime", 2)
gfx.text(4, 72, "SCALE 3", "lime", 3)
gfx.text(4, 98, "SCALE 6", "orange", 6)
Screen
Screen

The width returned is (4 × characters - 1) × scale: the last empty column is not counted.

Brass
print(gfx.text(1, 1, "HELLO", "white"))
print(gfx.text(1, 10, "HELLO", "white", 2))
print(gfx.text(1, 30, "", "white"))
Screen
19
38
0

Since the width is known in advance, centring is a subtraction. Keep such a helper at the top of your programs:

Brass
local s = gfx.size()
local function centered(y, text, color, scale)
  local width = (#text * 4 - 1) * scale
  gfx.text((s.w - width) // 2 + 1, y, text, color, scale)
end
gfx.clear("blue")
gfx.rect(1, 1, s.w, 30, "black", true)
centered(9, "STATION NORTH", "yellow", 3)
centered(60, "NEXT TRAIN", "white", 2)
centered(84, "2 MIN", "lime", 5)
centered(140, "PLATFORM 3, TO THE MINE", "white", 1)
Screen
Screen

The returned width also places the next piece of text, for a label followed by a value in another colour:

Brass
local status = {
  {"PRESSURE", "OK", "lime"},
  {"BOILER", "HEATING", "yellow"},
  {"STRESS", "OVERLOAD", "red"},
}
gfx.clear("black")
for i, row in ipairs(status) do
  local y = 12 + (i - 1) * 24
  local x = 10 + gfx.text(10, y, row[1], "white", 2)
  gfx.text(x + 8, y, row[2], row[3], 2)
end
Screen
Screen
Note

Text drawn with gfx.text is part of the drawing: it lies under the characters of print, it can be erased with "none" and it moves with gfx.scroll. Each character costs instructions (scale² each), so a big title drawn once is cheap, but redrawing a whole page of scale-3 text every tick is not.

See also gfx.image() term.write()

#

gfx.image(x, y, rows [, scale [, flip]])

⚙ cost rows + 1 per 64 px

Draws a sprite: a small picture written as text, one character per pixel.

Parameters
x number
column of the top left corner
y number
row of the top left corner
rows table
a list of strings, one per row of pixels, one hex digit per pixel
scale number optional
1 to 64, each pixel of the sprite becomes a square of this size (1 when left out)
flip boolean optional
true mirrors the sprite left to right

Each row is a string. Each character is a hex digit, the number of a colour: 0 white, 1 orange... 9 cyan, a purple, b blue, c brown, d green, e red, f black (capitals work too). Any other character, such as . or a space, is transparent: the pixel keeps what was there before. The full table is in Colours.

Brass
gfx.clear("light_blue")
gfx.rect(1, 110, 306, 62, "green", true)
local creeper = {
  "5d5dd5d5",
  "d5ddd5dd",
  "dffd5ffd",
  "5ffddff5",
  "dd5ffd5d",
  "d5ffffdd",
  "5dffffd5",
  "ddfd5fdd",
}
gfx.image(10, 10, creeper)
gfx.image(30, 10, creeper, 4)
gfx.image(76, 10, creeper, 8)
gfx.text(10, 84, "SCALE 1, 4 AND 8", "black")
local arrow = {
  "....4....",
  "....44...",
  "44444444.",
  "444444444",
  "44444444.",
  "....44...",
  "....4....",
}
gfx.image(170, 20, arrow, 4)
gfx.text(214, 32, "NORMAL", "black")
gfx.image(170, 96, arrow, 4, true)       -- mirrored, half over the grass
gfx.text(214, 108, "FLIP", "black")
Screen
Screen

scale makes each pixel a square, and flip mirrors the sprite, so a character walking left and right needs only one picture. Each row is mirrored on its own length: give every row the same length (pad with .), or a mirrored sprite falls apart.

A row that is not a string stops the program: bad argument #3 to 'image' (row 2 is not a string). There is no digit for "erase": to remove a sprite, draw a rectangle of the background colour (or of "none") over it.

See also gfx.text() Colours

Scrolling

#

gfx.scroll(dx, dy [, x, y, w, h])

⚙ cost 1 per 64 px of the area

Slides the drawing by (dx, dy). What leaves the area is lost, and what comes in on the other side is empty.

Parameters
dx number
pixels to the right (negative: to the left)
dy number
pixels down (negative: up); 0 when left out
x number optional
column of the top left corner of the area to move
y number optional
row of the top left corner of the area
w number optional
width of the area
h number optional
height of the area

With four more numbers, only that rectangle moves and the rest of the picture stays still: a status bar can stay on top while the landscape below scrolls. Give all four or none. The text of the screen does not move (that is term.scroll), and before any drawing it does nothing.

A scroll costs as much as filling its area once (one instruction per 64 pixels), whatever is drawn in it: a landscape made of hundreds of shapes moves for the price of one rectangle, and only the new strip at the edge needs drawing. This is how side-scrolling games and live charts work. Here the hills come in from the right, 2 pixels per tick, under a title bar that does not move:

Brass
local s = gfx.size()
local TOP = 20
gfx.clear("light_blue")
gfx.rect(1, 1, s.w, TOP - 1, "gray", true)
gfx.text(4, 7, "SCROLLING HILLS", "white")
local step = 0
while true do
  step = step + 1
  gfx.scroll(-2, 0, 1, TOP, s.w, s.h - TOP + 1)   -- everything under the bar, 2 pixels left
  local ground = 120 + math.floor(18 * math.sin(step / 9) + 7 * math.sin(step / 3.7))
  gfx.rect(s.w - 1, TOP, 2, ground - TOP, "light_blue", true)
  gfx.rect(s.w - 1, ground, 2, 4, "lime", true)
  gfx.rect(s.w - 1, ground + 4, 2, s.h - ground - 3, "brown", true)
  sleep(0.05)
end
Screen
Screen

See also term.scroll()

Patterns

Complete small programs that combine the functions above. They use sample numbers so that they run anywhere: in your world, the values come from peripheral.wrap(), inventory.count(), kinetic.speed() and the other devices.

A bar chart

The stock of a warehouse, one bar per item, scaled so that the largest fills the chart:

Brass
local stock = {
  {name = "IRON", count = 1450, color = "light_gray"},
  {name = "COPPER", count = 820, color = "orange"},
  {name = "ZINC", count = 610, color = "light_blue"},
  {name = "BRASS", count = 1980, color = "yellow"},
  {name = "ANDESITE", count = 2400, color = "gray"},
}
local s = gfx.size()
local BASE = s.h - 20       -- the bottom of the bars
local TALLEST = 110
local most = 0
for _, item in ipairs(stock) do
  most = math.max(most, item.count)
end

local function centered(x, w, y, text, color)
  gfx.text(x + (w - (#text * 4 - 1)) // 2, y, text, color)
end

gfx.clear("black")
gfx.text(6, 6, "WAREHOUSE STOCK", "white", 2)
gfx.line(4, BASE + 1, s.w - 4, BASE + 1, "gray")
for i, item in ipairs(stock) do
  local x = 14 + (i - 1) * 58
  local h = math.max(1, math.floor(item.count / most * TALLEST))
  gfx.rect(x, BASE - h + 1, 40, h, item.color, true)
  centered(x, 40, BASE - h - 7, tostring(item.count), "white")
  centered(x, 40, BASE + 6, item.name, item.color)
end
Screen
Screen

A gauge

A speedometer for a Create shaft: a half circle of coloured dots, ticks every 32 RPM, and a needle. The angle goes from π (left, 0 RPM) to 2π (right, 256 RPM). The sine of these angles is negative and y grows downwards on the screen, so the arc rises above its centre.

Brass
local CX, CY, R = 153, 124, 84
local rpm = 192
gfx.clear("black")
for degrees = 0, 180, 3 do
  local value = degrees / 180 * 256
  local color = "lime"
  if value > 224 then
    color = "red"
  elseif value > 160 then
    color = "yellow"
  end
  local a = math.pi + degrees * math.pi / 180
  gfx.pixel(CX + math.cos(a) * R, CY + math.sin(a) * R, color, 5)
end
for v = 0, 256, 32 do
  local a = math.pi + v / 256 * math.pi
  gfx.line(CX + math.cos(a) * (R - 16), CY + math.sin(a) * (R - 16),
    CX + math.cos(a) * (R - 8), CY + math.sin(a) * (R - 8), "white")
end
local a = math.pi + rpm / 256 * math.pi
gfx.line(CX, CY, CX + math.cos(a) * (R - 22), CY + math.sin(a) * (R - 22), "red", 3)
gfx.circle(CX, CY, 7, "light_gray", true)
local label = rpm .. " RPM"
gfx.text(CX - (#label * 4 - 1), CY + 18, label, "white", 2)
Screen
Screen

When the speed changes, there is no need to redraw the dial: draw the old needle again in black, then the new one.

Animate without flicker

The screen reaches the players every 2 ticks. If a frame takes longer than one tick of instructions to draw (a full clear and a redraw easily do), the program pauses in the middle of it and the players may see a half-drawn picture: the screen flickers. Two habits avoid it:

  • Do not clear the whole screen for every frame. Erase only what moves (a rectangle of the background colour over its old place), then draw it at its new place.
  • **Slide what is already drawn with gfx.scroll**, then draw only the new part. The picture is never empty, and the frame costs a fraction of a redraw.

A live chart of the stress of a factory works like this: each tick, the chart moves 2 pixels left and a single new segment is drawn at the right edge.

Brass
local X, Y, W, H = 10, 30, 286, 120      -- the chart area
gfx.clear("black")
gfx.text(10, 10, "STRESS", "white", 2)
gfx.rect(X - 1, Y - 1, W + 2, H + 2, "gray")
local last = Y + H // 2
local t = 0
while true do
  t = t + 1
  -- a sample reading from 0 to 1; in a world: stress / capacity of a kinetic device
  local usage = 0.5 + 0.35 * math.sin(t / 8) + 0.1 * math.sin(t / 2.3)
  local y = Y + H - 1 - math.floor(usage * (H - 1))
  gfx.scroll(-2, 0, X, Y, W, H)
  local color = "lime"
  if usage > 0.8 then color = "red" end
  gfx.line(X + W - 3, last, X + W - 1, y, color)
  last = y
  sleep(0.05)
end
Screen
Screen

A sprite animation

A small locomotive drives along its rails. It has two pictures whose wheels differ, shown in turn, and it is mirrored when it turns back. Each step erases the old place with a rectangle of the sky colour, so the rest of the picture is never redrawn.

Brass
local FRAMES = {
  {
    ".........7777...",
    ".cccc.....77....",
    ".c33c.....77....",
    ".cccceeeeeeeeeee",
    "4cccceeeeeeeeeee",
    "4cccceeeeeeeeeee",
    "ffffffffffffffff",
    "..f.f..f.f..f.f.",
    "...8....8....8..",
    "..f.f..f.f..f.f.",
  },
  {
    ".........7777...",
    ".cccc.....77....",
    ".c33c.....77....",
    ".cccceeeeeeeeeee",
    "4cccceeeeeeeeeee",
    "4cccceeeeeeeeeee",
    "ffffffffffffffff",
    "...f....f....f..",
    "..f8f..f8f..f8f.",
    "...f....f....f..",
  },
}
local SCALE = 4
local Y = 80
local s = gfx.size()
gfx.clear("light_blue")
gfx.rect(1, Y + 40, s.w, 4, "gray", true)            -- the rails
gfx.rect(1, Y + 44, s.w, s.h - Y - 43, "green", true)
gfx.text(8, 8, "THE MINE SHUTTLE", "white", 2)
local x, dx, frame = 10, 4, 1
while true do
  gfx.rect(x, Y, 16 * SCALE, 10 * SCALE, "light_blue", true)   -- erase the old place
  x = x + dx
  if x + 16 * SCALE > s.w or x < 1 then
    dx = -dx                                           -- turn back
    x = x + 2 * dx
  end
  frame = 3 - frame                                    -- 1, 2, 1, 2...
  gfx.image(x, Y, FRAMES[frame], SCALE, dx < 0)        -- mirrored when going left
  sleep(0.1)
end
Screen
Screen

Buttons drawn in pixels

Buttons of any size, anywhere, with a label in the pixel font. A click event carries e.px and e.py, the pixel under the mouse (or under the finger, on a monitor): the button hit is the one whose rectangle contains that point. See Screens and monitors for clicks on monitors.

Brass
local buttons = {
  {x = 12, y = 44, w = 88, h = 64, label = "PRESS", color = "orange", side = "left", on = true},
  {x = 109, y = 44, w = 88, h = 64, label = "MIXER", color = "light_blue", side = "right", on = false},
  {x = 206, y = 44, w = 88, h = 64, label = "FAN", color = "red", side = "top", on = false},
}

local function draw(b)
  local fill, ink = "gray", "white"
  if b.on then
    fill, ink = b.color, "black"
  end
  gfx.rect(b.x, b.y, b.w, b.h, fill, true)
  gfx.rect(b.x, b.y, b.w, b.h, "white", 2)
  local width = (#b.label * 4 - 1) * 2
  gfx.text(b.x + (b.w - width) // 2, b.y + b.h // 2 - 4, b.label, ink, 2)
end

local function inside(b, px, py)
  return px >= b.x and px < b.x + b.w and py >= b.y and py < b.y + b.h
end

gfx.clear("black")
gfx.text(12, 12, "LINE 2 CONTROLS", "white", 2)
gfx.text(12, 130, "CLICK A BUTTON TO SWITCH ITS MACHINE", "light_gray")
for _, b in ipairs(buttons) do
  draw(b)
end
while true do
  local e = os.pull_event("click")
  for _, b in ipairs(buttons) do
    if inside(b, e.px, e.py) then
      b.on = not b.on
      rs.set(b.side, b.on)
      draw(b)               -- redraw only the button that changed
    end
  end
end
Screen
Screen

The test itself is plain arithmetic, so you can check it without clicking:

Brass
local press = {x = 12, y = 40, w = 88, h = 40}
local function inside(b, px, py)
  return px >= b.x and px < b.x + b.w and py >= b.y and py < b.y + b.h
end
print(inside(press, 50, 60))
print(inside(press, 100, 60))
Screen
true
false

px < b.x + b.w, with a strict <, because a button 88 pixels wide starting at 12 ends at pixel 99.