Skip to contents

This article goes over the motivation behind the parts of the rendering system. It builds up from simple console printing to rendering animations ingame.

Example usage of the rendering functions can be found in their documentation, e.g. ?render.scene.

1. Goals

Game with graphics need a way to display their graphics. rcade is intended to run live-input games at relatively high fps (frames per second), so it needs a graphical system capable of redrawing itself at the desired framerate. Past achieving that, it’s a matter of getting as high a resolution as feasible.

2. Console Graphics

R plotting is far too slow to fulfill our needs. Instead, we turn to a canonical apporach— rendering via the console. Pixels can be printed as characters in the appropriate locations, giving a low-resolution but highly usable medium for rendering images. In fact, RStudio allows you to zoom out a considerable amount, allowing the console to fit upwards of 800x600 characters.

A couple issues come up. First, characters are much taller than they are wide, so we use pairs of characters—usually brackets: []—to create square pixels.

2.1 Pixel Art in the Console

Printing to the console like this is quite simple. Strings like this are quickly interpreted by users as imagery:

Example [click to expand]
cat('
  []  []
  []  []

[]      []
  [][][]
')

But we run into an issue. Art can be printed to the console, but there’s no guarantee it shows up in the right position vertically…

Luckily, clearing the console means that the next message will always show up in the same place— we can just clear the console every time we draw. RStudio supports this with the escape character cat('\f')1, though R.app unfortunately doesn’t.

2.2 Storing Pixel Art

If we want to print lots of images, we can’t just store everything as strings— it would be a nightmare to work with because of how clunky strings are to edit. Instead, we can write a function, render.matrix(), to convert directly from a bitmap matrix to a drawable string:

render.matrix
render.matrix = function(M, palette = c('  ', '[]', '  ')){
  #we assume the bitmap to be positive integers
  
  #attach a line of -1 values on the rightmost column to turn into newlines
  M = rbind(t(M),-1) #transpose to match the orientation of print(M)
  
  #add two to make all values work as vector indices
  M = M + 2
  
  #convert matrix to vector of strings using its values as indices
  str = c('\n',palette)[M]
  
    cat(str, sep='') #collapse vector and print
}

render.matrix(matrix(c(0,0,1,0,0,0,0,1,1,1,0,1,0,0,0,1,1,1,0,1,0,0,0,1,0,0,1,0), ncol = 7))
#>     []  []    
#>     []  []    
#> []          []
#>   [][][][][]

palette defines how we convert bitmap values to character pixels— by default, 1 encodes filled (black) pixels and 0 and 2 are white. Values of 0 are used elsewhere in the rendering system to represent transparency, but assume a white background here and draw them as white.

3. Sprites

So render.matrix() lets us print pixel art to the console if we have a matrix that encodes it. This package calls such a matrix a sprite.

Example
#A sprite is anything that can be printed by render.matrix():
sprite = matrix(c(0,0,1,0,0,0,0,1,1,1,0,1,0,0,0,1,1,1,0,1,0,0,0,1,0,0,1,0), ncol = 7)

print(sprite)
#>      [,1] [,2] [,3] [,4] [,5] [,6] [,7]
#> [1,]    0    0    1    0    1    0    0
#> [2,]    0    0    1    0    1    0    0
#> [3,]    1    0    0    0    0    0    1
#> [4,]    0    1    1    1    1    1    0

render.matrix(sprite)
#>     []  []    
#>     []  []    
#> []          []
#>   [][][][][]

Typically, the game stores and handles sprites directly (as matrices), and the game dev is expected to generate them on their own. The package provides a convenient tool for this: render.makesprite().

3.1 Sprite Creation

render.makesprite() converts user text to a sprite as intuitively as possible. The goal of this function is to remove the need for confusing matrix manipulation, and to follow the principle of WYSIWYG for maximal convenience: “what you see is what you get”.

Example
circle = render.makesprite('
  OOO
 O   O
O     O
O     O
O     O
 O   O
  OOO
')

render.matrix(circle)
#>     [][][]    
#>   []      []  
#> []          []
#> []          []
#> []          []
#>   []      []  
#>     [][][]

The limitation is that character strings will look a bit different from square pixels, but overall I’ve found the function to be extremely useful.

Sometimes, it’s easier to create a sprite manually as a matrix. This is especially useful for geometric or procedural shapes:

Example
box = matrix(1,8,16)
box[2:(nrow(box) - 1), 2:(ncol(box) - 1)] = 0

render.matrix(box)
#> [][][][][][][][][][][][][][][][]
#> []                            []
#> []                            []
#> []                            []
#> []                            []
#> []                            []
#> []                            []
#> [][][][][][][][][][][][][][][][]

4. Drawing Multiple Things at Once

render.matrix() lets us render an individual sprite to the console. The natural next step is to somehow combine sprites so they can be drawn together in one image.

The general approach to this is to take a big, empty, rectangular ‘background’ sprite, and edit in our sprites:

Example
sprite = render.makesprite('
 O O
 O O

O   O
 OOO
')

background = matrix(0,12,10)

#paste in sprites
background[1:5,1:5] = sprite
background[2 + 1:5, 5 + 1:5] = sprite
background[7 + 1:5, 3 + 1:5] = sprite

render.matrix(background)
#>   []  []            
#>   []  []            
#>             []  []  
#> []      []  []  []  
#>   [][][]            
#>           []      []
#>             [][][]  
#>         []  []      
#>         []  []      
#>                     
#>       []      []    
#>         [][][]

The problem with this is that replacing values directly with matrix[a:b,c:d] will always overwrite the original values. We can get around this with code like this to allow for sprites with transparency— we just encode transparent pixels as a value of 0.

  overwrite = sprite
  overwrite[sprite == 0] = matrix[a:b,c:d][sprite == 0] 
  #replace values of 0 with the value underneath 
    
  matrix[a:b,c:d] = overwrite
Example
background = matrix(0,9,9)

background[1:7,1:7] = circle

overwrite = circle
overwrite[circle == 0] = background[1 + 1:7,2 + 1:7][circle == 0] 
background[1 + 1:7,2 + 1:7] = overwrite

render.matrix(background)
#>     [][][]        
#>   []    [][][]    
#> []    []    [][]  
#> []  []      []  []
#> []  []      []  []
#>   [][]    []    []
#>     [][][]    []  
#>         [][][]    
#> 

This whole process is automated by render.overlay(), which also clips the sprite to ensure that everything is kept within the bounds of the matrix.

Example
background = matrix(0,9,14)

background = render.overlay(background, circle, 1, 1)
background = render.overlay(background, circle, 3, 3)
background = render.overlay(background, circle, 11, 1)

render.matrix(background)
#>     [][][]              [][]
#>   []      []          []    
#> []      [][][]      []      
#> []    []    [][]    []      
#> []  []      []  []  []      
#>   [][]    []    []    []    
#>     [][][]      []      [][]
#>       []      []            
#>         [][][]

5. Layers and the Scene

Now that we can combine sprites, we run into the issue of convenient ordering. Which sprites should be drawn on top, and how do we achieve that?

rcade addresses this with render.scene(). We create a ‘scene’ object containing layers, each of which is their own background from the previous section. The engine draws sprites onto specific layers, and then finally takes all the layers and overlays them one-by-one to produce the final matrix to be drawn.

render.scene() stacks individuals layers in a scene into a single sprite matrix to be drawn by render.matrix().
render.scene() stacks individuals layers in a scene into a single sprite matrix to be drawn by render.matrix().

To draw a sprite onto a layer, we use render.sprite(), which is really just a wrapper for render.overlay(layer,sprite).

5.1 The Inversion Layer

In monochrome displays, you’ll sometimes run into the issue where something important, like text, is drawn over a black background and becomes impossible to see. A simple fix is to, rather than drawing the text as set color, just allow it to invert the color it’s drawn on— that way it’ll always be visible2.

rcade implements this with a dedicated scene layer called scene$layers$invert, which is drawn last and inverts anything it covers. This uses the invert argument of render.overlay().

Example
scene = list(width=48, height=12)
block = upper.tri(matrix(1,12,32))[12:1,]

scene = render.sprite(scene,block,12,1)

txt = render.text('inversion\nof colors')

scene = render.sprite(scene,txt,2,1,layer='invert')

render.scene(scene)
Zoom out if this is hard to read.
Zoom out if this is hard to read.

6. Drawing the RAM

The game rendering process involves drawing every game object onto one scene. This is automated by render.ram(), which is called every frame— see vignette("timing").

In this function, a scene object is created, and then each game object (in RAM$objects) is drawn to it with render.sprite().

6.1 Sprites in ROM

By convention, sprites/images are stored in ROM when possible.3 They are stored by name in ROM$sprites, and referenced during the rendering process.

6.2 Object Drawing Specifics

Object drawing is handled by render.object(), which figures out how and where to draw the object with render.sprite().

Objects are only drawn if they have a $spritename, which indicates which sprite they use. This is a character string corresponding to a sprite matrix in ROM$sprites— then render.object() retrieves the sprite from ROM$sprites and feeds it into render.sprite().

Objects have quite a few optional properties, listed in ?render.object. These control things like position and whether or not this particular object should swap around the colors in its sprite. ‘palette swapping’ of the colors like this is an easy way to increase the variety of images in a game without needing to make new sprites.

Example
sprite = render.makesprite('

 ...
.O=O.
.....
 ===
 
',lookup = c(' ' = 0, '.' = 1, '=' = 2, 'O' = 3))

render.matrix(sprite, palette=c('  ','..','==','()'))
#>           
#>   ......  
#> ..()==()..
#> ..........
#>   ======  
#> 
render.matrix(sprite, palette=c('  ','..','++','\\/'))
#>           
#>   ......  
#> ..\/++\/..
#> ..........
#>   ++++++  
#> 
render.matrix(sprite, palette=c('  ','||','MM','()'))
#>           
#>   ||||||  
#> ||()MM()||
#> ||||||||||
#>   MMMMMM  
#> 

6.3 Custom Drawing Code

It is often useful to override this basic drawing behavior— for example, if an object’s sprite should be generated procedurally rather than copying from a static sprite in ROM$sprites. This can be done by defining obj$draw(), which will run instead of the regular code in render.object().

Note that obj$draw() should return the scene, and thus must not include any code that changes values in the object itself; those changes should occur in game code in ROM$custom() (see vignette("engine")).

7. Animations

Now to the topic of animations. All we have to do is make a game object change its sprite every few frames:

This is facilitated by calling render.animate() when we retrieve an object’s sprite in render.object(). For sprites we want to animate, instead of storing just the pixel art matrix, we store a list containing each frame of animation and some other information about the animation. render.animate() then parses these to figure out what frame of animation it should return.

Example: TODO. See render.animate() in the meantime.

We can also specify whether an animation should loop or transition to a different sprite after completing, by giving the list a value for next_animation.

So for the game dev, all that’s needed to animate a sprite is to store it as a complex sprite in ROM$sprites and add some metadata about the animation. Very convenient! Sprite animations can also be tested with render.test_animation to ensure they look as they should.

8. Text

Now for the topic of rendering a piece of text ingame. We could just render.sprite() each character individually, but this is needlessly ineffecient; instead, the best solution is to combine all the characters into one sprite and render that in a single render.sprite() call. This is the purpose of render.text().

8.1 Fonts

But first, how should store the sprites for each character? rcade handles these pretty simply— it has font objects which contain some information about the size/spacing of the characters, and also contain a list of sprites for each character. ?render.makefont provides more detail on this, and inspecting View(fonts.3x3) may be useful as well.

8.2 render.text()

The basic functionality of render.text() is stitching together character sprites with the desired (or default) spacing. It outputs a sprite, to put in ROM$sprites or draw on the fly in obj$draw(). Putting text on the inversion layer (with render.sprite(...layer = 'invert') may be helpful to ensure that the text is readable on both black or white backgrounds.

Additionally, some code is included to provide the option to wrap words to fit in a given width of pixels, and align them to the left/right/center.4

Here’s an example of render.text()’s stitching:

Example
#manually stitching letters with a 1-wide gap between
render.matrix(
  cbind(
    fonts.3x3$sprites$T,
    matrix(0,3,1),
    fonts.3x3$sprites$E,
    matrix(0,3,1),
    fonts.3x3$sprites$X,
    matrix(0,3,1),
    fonts.3x3$sprites$T
  )
)
#> [][][]  [][][]  []  []  [][][]
#>   []    [][]      []      []  
#>   []    [][][]  []  []    []

#render.text() does this automatically
render.matrix(
  render.text('text', kerning = 1)  
)
#>                                 
#> [][][]  [][][]  []  []  [][][]  
#>   []    [][]      []      []    
#>   []    [][][]  []  []    []    
#> 

?render.text provides some more examples of its capabilities.

9. Rendering Pipeline

Let’s go through the process to get an ingame object drawn on the console.

9.1 Setup

  1. We start with an object called Blueberry. It’s at the xy coordinates (5,6) and is on layer 5 of the game scene.

  2. Blueberry is stored in RAM as the list RAM$objects$blueberry, and has the following elements:

$x: 5
$y: 6
$spritename: 'blueberry'
$layer: 5
  1. We also have a corresponding sprite stored as RAM$ROM$sprites$blueberry as a simple matrix.

9.2 Pipeline

  1. We’re going to draw the current frame of RAM. This starts with calling render.ram(), which initializes a scene onto which all the sprites will be drawn.

  2. render.ram() iterates through every object in RAM$objects and calls render.obj() on it to draw it on the scene.

  3. So render.obj() is called on RAM$objects$blueberry. This reads Blueberry’s elements and feeds them into render.sprite():

  4. render.sprite() retrieves Blueberry’s sprite from RAM$ROM$sprites and draws it (using render.overlay()) on layer 5 of the scene at coordinates (5,6).

  5. After render.obj() is called for all objects, render.scene() is called, which stacks the layers of the scene onto one matrix using render.overlay().

  6. This single matrix is fed into render.matrix(), which clears the console and then renders the matrix.

10. Flickering

The timing system (vignette("timing")) is robust enough that slow draw times (i.e. for large resolutions) won’t impact the fluidity of gameplay. However, the basic drawing system has a big flaw: flickering.

base::cat() prints output in large chunks of characters, and each chunk takes a tiny bit of time to print. (Try cat(rep(1,100000)): it takes a good second for the whole output to finish printing). This creates a problem: when we clear the console with '\f', the first chunk of characters are immediately printed to fill the blank space, but it takes a split second for the rest of the characters to get drawn. The background is blank during that split second, so we end up with a brief flicker of white on every frame that gets stronger the more characters (including whitespaces!) are drawn.

Thankfully, there’s a workaround! We can abuse text wrapping to render things all on “one line” whi, and then use '\r' to overwrite it each frame. All we need to do is replace '\n' with a long series of tab characters—'\t'—which have the property of essentially doing the same thing visually as '\n' when they cause the text to wrap.

This has several benefits: in addition to removing flickering, this lets us animate the game without having to clear the console with '\f'— which means it can even run in R.app!

I discovered this workaround very late into working on rcade: I plan on making it the package’s default, but haven’t gotten around to it yet.