ANSI Sprites: Animation in a Terminal UI Toolkit
July 28, 2026
A terminal is a grid of characters. Each cell holds one glyph, one foreground color, one background color. That’s it. There are no pixels, no sprites, no animation frames. At least, that’s what you’d think.
jterm’s sprite library takes that grid and turns it into a animation engine. ANSI art frames become sprite frames. A sprite sheet — a single ANSI file with a grid of cells — becomes a flipbook. Virtual threads drive the animation loop. And the terminal becomes a screen.
The Three Classes
The sprite system is three classes:
Sprite— a collection of ANSI art frames with loop modes and timingSpriteSheet— loads a single ANSI file and slices it into a grid of framesSpriteRenderer— drives animation on virtual threads, handles frame updates and cleanup
Each one does one thing. Let’s break them down.
Sprite: The Flipbook
A Sprite is a list of frames — each frame is a raw ANSI string (text with embedded SGR escape sequences for colors and styles). The sprite advances through frames based on elapsed milliseconds, not frame counts. This makes animation speed independent of the render loop’s framerate:
Sprite sprite = new Sprite();
sprite.addFrame(AnsiArtRenderer.fromFile(Path.of("frame1.ans")));
sprite.addFrame(AnsiArtRenderer.fromFile(Path.of("frame2.ans")));
sprite.addFrame(AnsiArtRenderer.fromFile(Path.of("frame3.ans")));
sprite.setFrameMs(100); // 10 FPS
sprite.setLoopMode(Sprite.LoopMode.LOOP);
Three loop modes control how frames cycle:
public enum LoopMode {
ONCE, // play through, stop at last frame
LOOP, // play through, wrap to frame 0
PING_PONG // forward to end, backward to start, repeat
}
ONCE is for intro animations — play a sequence, then freeze on the final frame. LOOP is for idle animations — a spinning logo, a blinking cursor, a flickering torch. PING_PONG is for natural back-and-forth motion — a breathing effect, a pendulum, a wave.
The advance(long elapsedMs) method accumulates elapsed time and steps frames accordingly. Call it with the delta from your main loop:
public void advance(long elapsedMs) {
if (finished || frames.isEmpty()) return;
accumulatedMs += elapsedMs;
while (accumulatedMs >= frameMs) {
accumulatedMs -= frameMs;
step();
if (finished) {
accumulatedMs = 0;
return;
}
}
}
The step() method handles the loop mode logic — wrapping for LOOP, reversing direction for PING_PONG, setting the finished flag for ONCE. It’s a simple state machine, but it’s the right abstraction: you don’t think about frame indices, you think about time.
SpriteSheet: Slicing ANSI Art
Most sprite animations aren’t hand-coded frame by frame. You draw them in an ANSI art editor — PabloDraw, Moebius, etc. — as a sprite sheet: a grid of cells laid out in a single file. The SpriteSheet class loads that file and slices it into individual frames:
// Load a 4×4 grid of 8×8-cell frames from a single ANSI file
SpriteSheet sheet = SpriteSheet.fromFile(
Path.of("character.ans"), 8, 8, 4, 4);
Sprite sprite = sheet.toSprite();
sprite.setFrameMs(80);
sprite.setLoopMode(Sprite.LoopMode.LOOP);
The grid is cellWidth × cellHeight cells arranged in cols columns and rows rows. Frames are extracted left-to-right, top-to-bottom (row-major order):
[0] [1] [2] [3]
[4] [5] [6] [7]
[8] [9] [10] [11]
[12] [13] [14] [15]
The tricky part is preserving SGR (color/style) state when slicing. ANSI art uses escape sequences like \033[31m (red foreground) that set state persistently. If you naively slice a region out of the middle of a line, you lose the color that was set earlier. The sliceWithSgr method handles this:
private static String sliceWithSgr(String line, int startCol, int width) {
// First pass: capture active SGR state at startCol
// Walk the line, tracking visible column position.
// Any SGR sequence encountered before startCol is "carried forward"
// and prepended to the output.
// Second pass: build the output slice
// Include visible characters in [startCol, startCol+width)
// Include SGR sequences encountered within that window
// Pad with spaces if the line was shorter than expected
}
This means each extracted frame is a standalone ANSI string that renders correctly on its own — colors intact, no broken state. You can drop it straight into a Sprite and it just works.
SpriteRenderer: The Animation Engine
The SpriteRenderer is where animation actually happens. It runs each animation on a dedicated virtual thread and provides AnimationHandle objects for control:
SpriteRenderer renderer = new SpriteRenderer(graphics, terminalSize);
// One-shot animation with completion callback
renderer.playOnce(introSprite, 10, 5, () -> {
System.out.println("Intro complete — entering main menu");
});
// Looping animation — stop later
AnimationHandle handle = renderer.playLoop(torchSprite, 30, 0);
Thread.sleep(5000);
handle.stop();
Each animation runs on Thread.ofVirtual(), so you can have dozens of concurrent sprite animations without exhausting platform threads. The animation loop is straightforward:
thread = Thread.ofVirtual().name("sprite-anim").start(() -> {
long lastNanos = System.nanoTime();
while (running.get()) {
long now = System.nanoTime();
long deltaMs = (now - lastNanos) / 1_000_000L;
lastNanos = now;
sprite.advance(deltaMs);
synchronized (renderer.graphics) {
sprite.render(renderer.graphics, col, row);
}
if (sprite.isFinished()) {
running.set(false);
break;
}
Thread.sleep(Math.max(16, sprite.getFrameMs() / 2));
}
if (callback != null) callback.run();
renderer.removeHandle(this);
});
The sleep is capped at a minimum of 16ms (roughly 60fps) to avoid burning CPU on fast frame rates. The synchronized block on the graphics buffer prevents concurrent writes from multiple animations — they take turns painting into the shared buffer.
Stale Frame Cleanup
When a sprite moves position, the renderer clears the old region before drawing the new frame. Without this, you’d see ghost trails — the previous frame’s characters left behind:
private void clearRegionIfNeeded(Sprite sprite, int col, int row) {
if (lastSprite == sprite && (col != lastCol || row != lastRow)) {
clearRegion(lastCol, lastRow, lastWidth, lastHeight);
}
}
The renderer tracks the last position and dimensions of each sprite it draws. When the same sprite is rendered at a new position, the old region is filled with blank cells before the new frame is painted. This gives clean movement — no artifacts, no residue.
Rendering a Frame
The Sprite.render() method delegates to AnsiArtRenderer, which parses the raw ANSI string and writes individual TextCell objects into the graphics buffer:
public void render(TextGraphics g, int startCol, int startRow) {
AnsiArtRenderer.render(g, getCurrentFrame(), startCol, startRow);
}
Each cell in the ANSI frame becomes a TextCell — a character, a foreground color, and a background color. The graphics buffer is the same one used by every other jterm widget, so sprites compose naturally with panels, labels, tables, and borders. A sprite can be rendered on top of a bordered panel, or alongside a list of menu items, or as a background animation behind a login screen.
Why This Matters
Terminal animation isn’t new — BBSes in the 90s had ANSI animations. But they were usually pre-rendered sequences played back as raw bytes. jterm’s sprite system is different:
- Frame-level control — you can stop, start, loop, and ping-pong individual sprites. You’re not just dumping bytes to the terminal.
- Sprite sheets — load a single ANSI file, slice it into frames. No need to manage dozens of individual files.
- Virtual threads — each animation runs on its own virtual thread. No thread pool management, no executor service, no platform thread overhead.
- Composable — sprites render into the same graphics buffer as everything else. They’re first-class citizens in the widget system, not a separate rendering path.
- SGR preservation — the sprite sheet slicer correctly carries color state across cell boundaries. Each frame renders with the right colors, even when sliced from the middle of a line.
The result: you can build animated door games, login screen effects, scrolling marquees, pulsing borders, and any other terminal animation you can imagine — all in pure Java, all on the terminal grid, all with the full ANSI color palette.