Scrolling Text
Let’s experiment with scrolling text. A bit of thrashing but a fairly decent object emerges.
Wednesday Afternoon
The basic idea is that dungeon messages will be displayed on the main screen, starting in some reasonable place and scrolling upward for a while and then vanishing. So, hm, how might we do that? We’ll have to experiment, but …
I guess there will be some kind of text buffer with lines of text. We won’t worry about word-wrapping or anything fancy, at least at first and probably forever. There will be some Y coordinates where the text will start and where it will finish. It might be that we should use a separate camera so that the dungeon can be scrolled while the text is drawn and scrolled differently. I think there is even a UI kind of text box but that may not be just the thing.
I think I”ll begin with a simple text line in mid-screen.
Thursday Morning
At the point above, much experimentation ensued, associated with much frustration and a modicum of curseration. I stopped for the afternoon, read a bit about Arcade, and tried a little something in Lua. Now I have a plan.
I just tried to explain my plan in words, to little avail. You know what? Let’s just do it, the code will be a good basis for explaining.
First move will be to display a few lines on the screen.
We define a new camera to draw our text, since it doesn’t want to zoom and scroll with the map.
def setup_camera(self):
self.camera = arcade.Camera2D()
# ignore very messy setup of main camera
self.text_camera = arcade.Camera2D()
In on_draw, we have this:
def on_draw():
...
with self.text_camera.activate():
messages = [
'You have discovered a gold coin!',
'There is a small cage here.',
'Your lamp has gone out!!',
'You have been eaten by a grue!',
]
x,y = 512, 800
height = 36
dy = 0
for message in messages:
text = arcade.Text(message, x=x, y=y-dy, font_size=24)
width = text.content_width
text.x = text.x - width // 2
text.draw()
dy += height
The numbers are all hand-crafted. The 36 is the line height of a line of text as specified. So we set each line of text one line further down than the previous. Sort of like we want in the scroll.

Now we can make it scroll by incrementing the height just a bit on each update. I think it’s time to turn this open code into a rudimentary Scroller.
I think we’ll try some TDD. What I’ve done above isn’t any too terrific and I struggled with it for reasons I can’t explain.
class TestScroller:
def test_initial(self):
scroller = Scroller()
for message in [
'You have discovered a gold coin!',
'There is a small cage here.',
'Your lamp has gone out!!',
'You have been eaten by a grue!',
]:
scroller.announce(message)
assert len(scroller.buffer) == 4
And, not surprisingly:
class Scroller:
def __init__(self, minimum=4):
self.buffer = []
self.minimum = minimum
def announce(self, message: str) -> None:
self.buffer.append(message)
That runs green. Now, what I have in mind is that the scroller will always be running, and that its default line to display is blank. (We might improve this later but it seems to make the thing much easier to build.)
When we draw, we’ll ask it for lines … by index, I think. If we ask for more lines than it has it returns ''. Let’s TDD that.
def test_blank_when_no_message(self):
scroller = Scroller()
for message in [
'You have discovered a gold coin!',
'There is a small cage here.',
]:
scroller.announce(message)
assert scroller.message(0) == 'You have discovered a gold coin!'
assert scroller.message(2) == ''
And:
def message(self, number: int) -> str:
try:
return self.buffer[number]
except IndexError:
return ''
Is that Pythonic? Is asking forgiveness better than asking permission? We’ll accept it.
In use, I’d like the Scroller to know the base x and y where it will start drawing, and the line height and such. I’d like to hard-code those for now. We should also convert our messages to arcade.Text once, not on every draw. We’ll leave that for later as well.
I want to have a method to return the y values for each line, which I plan to use in the on_draw. I’m not sure what I really want, so let’s code the on_draw even though we can’t test-drive it. I want to use it to find out what my helper method, which we can test-drive, should be.
def on_draw(self):
for line in range(self.lines):
y_pos = self.y_position(line)
message = self.message(line)
text = arcade.Text(message, x=self.base_x, y=y_pos, font_size=24)
text.x = text.x - text.content_width // 2
text.draw()
OK, I just want y_position to return the desired y position of the n-th line. Test that:
def test_y_position(self):
scroller = Scroller(4, (666, 536))
assert scroller.y_position(0) == 536
assert scroller.y_position(1) == 500
And provide:
class Scroller:
def __init__(self, lines=4, base=(512,800)):
self.buffer = []
self.base_x, self.base_y = base
self.lines = lines
self.line_height = 36
def y_position(self, line_number):
return self.base_y - line_number * self.line_height
Lots of magic numbers in there, but no matter. I’m ready to try the Scroller on screen. Back in DungeonView:
class DungeonView:
def setup(self):
self.setup_scroller()
self.setup_camera()
self.shape_list = arcade.shape_list.ShapeElementList()
self.create_rooms(self.shape_list)
def setup_scroller(self):
self.scroller = Scroller(lines=4, base=(512,800))
for message in [
'You have discovered a gold coin!',
'There is a small cage here.',
'Your lamp has gone out!!',
'You have been eaten by a grue!',
]:
self.scroller.announce(message)
def on_draw(self):
...
with self.text_camera.activate():
self.scroller.on_draw()
We get a picture just like the one above. So that’s working.
Now, what about update? We want the lines to scroll upward, with increasing Y coordinates, until they’ve gone up one line, at which point we want to vanish the top line and carry on.
We’ll TDD that. We’ll want an additional bump for y, ranging from 0 to line height, in small increments. I don’t know how much so I’ll just test like this:
def test_y_position_update(self):
scroller = Scroller(4, (666, 536))
assert scroller.y_position(0) == 536
assert scroller.y_position(1) == 500
scroller.on_update()
assert scroller.y_position(0) > 536
It should be bigger, but by how much I just don’t know.
class Scroller:
def on_update(self):
self.y_bump += 1
if self.y_bump >= self.line_height:
self.y_bump = 0
Now we need an on_update in the view. I don’t think we have one yet.
class DungeonView:
def on_update(self, delta_time: float):
self.scroller.on_update()
I think that if I run now, the scroller will scroll everything off. We’ll see. No, I forgot the key trick, removing the message at the top. Also a bit more fudge was required.
class Scroller:
def on_update(self):
self.y_bump += 0.5
if self.y_bump >= self.line_height:
self.y_bump = 0
self.buffer.pop(0)
if len(self.buffer) < self.lines:
self.announce('')
def y_position(self, line_number):
return self.base_y - line_number * self.line_height + self.y_bump
We need to remove the top line with pop, and if there are not at least lines lines, append a blank line to the buffer. Without that, new messages start up near the top and we want them always to go at the bottom. But we don’t want to insert extra blank lines between messages.
This is working. I’ve added a couple of keystrokes that insert messages, just for demo purposes. Here’s a movie:
I think we can call this a success. It has been a bit ragged. I’ll take a break now and consider further in a subsequent article.
Summary
Although the scheme is pretty simple, I was making a lot of silly mistakes and certainly there are too many magic numbers in there. But the basic ideas are there:
- Have some known number of lines to be in the scrolled display at a time;
- When there are no substantive lines, fill with blank lines;
- Append new messages at the end of the list;
- Draw from the top of the list, which may contain blanks, ensuring that new lines enter at the bottom of the scroll;
- On update, add a little bump to the y coordinate, making the lines scroll upward;
- When the bump reaches one line height, pop off the top line, appending a blank line if needed to keep the line count at least at the “known number” of lines.
I’m glad I did the TDD, it gave me a place to think about and isolate some of the Scroller bits that needed a bit more thinking than I’m likely to be able to do inside on_draw without a lot of printing.
And the good news is that it’s all rather nicely embedded in a single object, the Scroller.
After a break, we’ll review what we have and see how it can be improved. But it moves!
See you next time!