The Bandwidth Problem & The Metatile Cure
When building real-time, remote-rendered games on the Commodore 64, your biggest bottleneck is the serial transfer speed. Standard C64 screens comprise exactly 1000 character cells (40 columns by 25 rows) of Screen RAM and a parallel 1000 bytes of Color RAM.
To redraw a complete screen over a high-speed 38400 baud serial connection takes over half a second. This is too slow to support fluid side-scrolling, map transitions, or full-grid rendering in fast action games.
🎯 The 10:1 Compression Cure: Metatiles
Instead of sending 1000 individual character write commands to the screen, we group character cells into pre-configured 2x2 Metatiles (representing a 2x2 grid of 4 character cells, i.e., a 16x16 pixel block).
By using metatiles, the server compresses the entire 40x25 character grid into a incredibly light 20x12 tile matrix. Instead of sending 1000 bytes, the server only needs to stream 240 bytes over the wire! This yields an immediate, massive data compression ratio, allowing high-performance, fluid map scrolling and instantaneous screen transitions over a slow serial link!
The Metatile Layout Technique
RIFT64 actually exposes three related rendering modes via the D command, all driven by the same MT_RenderWindow routine in metatile.asm:
- Mode 1 — Raw 1×1: each map byte is written verbatim as a screen character. Effectively a windowed character blit with optional parallel colour map. Best for HUDs, dialog boxes, and any pre-rendered text/PETSCII overlay you want to push as a single block.
- Mode 2 — 2×2 metatiles: each map byte is a tile id (0-255). Every tile expands to 4 character cells (16×16 px). The sweet spot for platformers, RPG maps, and most tile-grid games — 4× compression with full-screen update in well under a frame's worth of bytes.
- Mode 3 — 3×3 metatiles: each map byte expands to 9 character cells (24×24 px). 9× compression; great for chunky overworld maps, board games, and Sokoban-style puzzles where individual tiles are visually large.
Parallel Slot-Page Storage
The client does not store metatile cells as 4 (or 9) contiguous bytes per id. Instead it uses parallel slot pages: each cell position in the tile gets its own page-aligned 256-byte table indexed by tile id, so look-up at render time is a single absolute-Y load with no multiply.
For a 2×2 metatile bank, you upload 4 contiguous pages (1 KB total) starting at a page-aligned address $HH00. Only the high byte HH is given to the renderer (metaHi):
Bank base = $HH00 ┌──────────────────────────┐ ──────────────────────│ Slot 0 (Top-Left) │ 256 bytes, indexed by tile id $HH00 + id └──────────────────────────┘ ──────────────────────┌──────────────────────────┐ $HH00 + $0100 + id │ Slot 1 (Top-Right) │ ──────────────────────└──────────────────────────┘ ──────────────────────┌──────────────────────────┐ $HH00 + $0200 + id │ Slot 2 (Bottom-Left) │ ──────────────────────└──────────────────────────┘ ──────────────────────┌──────────────────────────┐ $HH00 + $0300 + id │ Slot 3 (Bottom-Right) │ ──────────────────────└──────────────────────────┘
Mode 3 follows the same scheme with 9 pages (2.25 KB) laid out row-major: slots 0/1/2 = top row, 3/4/5 = middle row, 6/7/8 = bottom row. Mode 1 has no metatile bank — the renderer reads characters directly from the map.
The D Command in One Round Trip
A single D (Draw Metatile) command renders an entire window of the map in one ACK‑validated round trip. You give it the map address and dimensions, the metatile bank's high byte, the source map offset (OffX, OffY), the destination screen coordinate / stride, the window size in characters, and a fill character used for any cells that fall outside the map. Optionally you can also drive colour RAM in four modes: NONE, FILL (one colour everywhere), MAP (a parallel colour table; for modes 2/3 it's a 256-byte colour-per-tileId lookup, for mode 1 a per-cell colour map), or PERCELL (mode-2 only; a 1 KB page-aligned bank giving each child cell of every metatile id its own colour).
Sub-Tile Scroll Offsets
OffX and OffY are character-cell offsets inside the first metatile, clamped by the client to the range 0 .. (mode − 1). They let you nudge the camera by half a tile (mode 2) or by 1 / 2 of a tile (mode 3) without redrawing the map. To scroll further you advance the source mapAddr (or x/y) by whole tiles and reset the offset.
- Mode 1:
OffX/OffYmust be 0 (the renderer clamps to 0). - Mode 2: valid values are 0 or 1.
- Mode 3: valid values are 0, 1, or 2.
Implementing Metatiles with the C# SDK
The full SDK signature mirrors the asm config block one-to-one:
Task<bool?> DrawMetatileAsync(
byte mode, // 1 = 1x1, 2 = 2x2, 3 = 3x3
ushort mapAddr, // base of the tile-id map in C64 RAM
byte mapW, byte mapH,
byte metaHi, // hi byte of page-aligned metatile bank
ushort targetAddr, // usually $0400 (screen RAM)
byte stride, // usually 40
byte winW, byte winH, // window size in CHARACTER cells
byte x, byte y, // destination top-left in screen cells
byte offX, byte offY, // sub-tile char offset, 0..(mode-1)
byte fillChar = 32,
byte colorMode = 0, // 0=NONE, 1=FILL, 2=MAP
ushort colorTgtAddr = 0xD800,
ushort colorSrcAddr = 0,
byte colorFill = 14,
TimeSpan timeout = default);
Step 1: Upload a 2×2 Metatile Bank to RAM
Build the bank as four parallel 256-byte slot pages — one page per cell position — and upload them to a page-aligned address. Index id in each page is the character code for that corner of tile id.
// Bank base must be page-aligned. We'll use $4000.
const ushort bankBase = 0x4000;
var bank = new byte[4 * 256];
// Tile 0 = "grass" : TL=$30 TR=$30 BL=$31 BR=$31
bank[0 * 256 + 0] = 0x30; bank[1 * 256 + 0] = 0x30;
bank[2 * 256 + 0] = 0x31; bank[3 * 256 + 0] = 0x31;
// Tile 1 = "brick" : all four corners $A0
bank[0 * 256 + 1] = 0xA0; bank[1 * 256 + 1] = 0xA0;
bank[2 * 256 + 1] = 0xA0; bank[3 * 256 + 1] = 0xA0;
// Push the four pages up to the C64 (StoreMemoryLargeCheckedAsync chunks & verifies)
await client.StoreMemoryLargeCheckedAsync(bankBase, bank);
Step 2: Upload a Tile-ID Map and Render a Window
The map is just a flat array of tile ids (one byte per tile). A single D command then draws as much of that map as fits in the requested window — one packet, one ACK.
// 20x12 tile map = covers the full 40x24 character region in mode 2
const ushort mapAddr = 0x6000;
var map = new byte[20 * 12]; // fill with tile ids 0/1/...
await client.StoreMemoryLargeCheckedAsync(mapAddr, map);
// Draw the entire 20x12 metatile map (= 40x24 chars) at screen (0,0).
await client.DrawMetatileAsync(
mode: 2,
mapAddr: mapAddr,
mapW: 20, mapH: 12,
metaHi: 0x40, // bankBase $4000 -> hi byte $40
targetAddr: 0x0400,
stride: 40,
winW: 40, winH: 24, // window size in CHARACTERS
x: 0, y: 0,
offX: 0, offY: 0,
fillChar: 32,
colorMode: 1, // FILL all cells with one colour
colorFill: (byte)Rift64Color.LightGreen,
timeout: TimeSpan.FromSeconds(2));
Smooth Scrolling
OffX/OffY shift the camera by one character cell at a time within the first tile. To scroll past a whole tile you reset the offset and advance the source map — either by passing a new mapAddr base, or by reissuing the same packet with the same map and shifting x/y by one tile in the opposite direction. The map and metatile bank stay in C64 RAM — you only resend the 20-byte D packet.
// Pixel-level horizontal scroll across a 64-tile-wide world (mode 2).
int cameraCharsX = 0; // scroll position in CHARACTER cells (= half-tiles)
while (running)
{
byte tileX = (byte)(cameraCharsX >> 1); // whole tiles already scrolled
byte offX = (byte)(cameraCharsX & 1); // 0 or 1: sub-tile shift
await client.DrawMetatileAsync(
mode: 2,
mapAddr: (ushort)(mapAddr + tileX), // advance source by whole tiles
mapW: 64, mapH: 12,
metaHi: 0x40,
targetAddr: 0x0400, stride: 40,
winW: 40, winH: 24,
x: 0, y: 0,
offX: offX, offY: 0,
timeout: TimeSpan.FromMilliseconds(50));
cameraCharsX++;
await Task.Delay(20);
}
Worked Example: How One Cell Is Resolved (Mode 2)
Suppose mapAddr = $6000, mapW = 20, metaHi = $40, and the render is asked to draw the cell at character position (2, 3) in the destination window with OffX/OffY = 0:
- Tile column & row:
tx = 2 >> 1 = 1,ty = 3 >> 1 = 1. - Tile id read from the map:
id = peek($6000 + ty * 20 + tx) = peek($6014). - Sub-tile cell:
cx = 2 & 1 = 0,cy = 3 & 1 = 1→ slotcy*2 + cx = 2(Bottom-Left). - Character code:
peek($4000 + slot*$0100 + id) = peek($4200 + id). - Written to
$0400 + 3*40 + 2 = $047A.
Mode 1 Example — Raw HUD / Dialog Blit
Mode 1 treats every map byte as a screen character code, so it's perfect for pre-rendered overlays: a status bar, a popup dialog, or a PETSCII title card. Pair it with colorMode = 2 (MAP) to get a parallel per-cell colour overlay.
// 16x4 dialog box. Map is 16*4 = 64 chars; colour map is parallel 64 bytes.
const ushort dlgChars = 0x6800;
const ushort dlgColors = 0x6840;
var chars = new byte[16 * 4];
var colors = new byte[16 * 4];
// ... fill chars/colors with your dialog content ...
await client.StoreMemoryLargeCheckedAsync(dlgChars, chars);
await client.StoreMemoryLargeCheckedAsync(dlgColors, colors);
// Pop the dialog at screen (12, 10), 16x4 chars in size.
await client.DrawMetatileAsync(
mode: 1,
mapAddr: dlgChars,
mapW: 16, mapH: 4,
metaHi: 0, // not used in mode 1
targetAddr: 0x0400, stride: 40,
winW: 16, winH: 4, // chars (mode 1: same as map)
x: 12, y: 10,
offX: 0, offY: 0,
fillChar: 32,
colorMode: 2, // MAP - parallel per-cell colour
colorTgtAddr: 0xD800,
colorSrcAddr: dlgColors,
timeout: TimeSpan.FromSeconds(1));
Tip: Snapshot the screen with SaveScreenBufferAsync(0) first, draw the dialog, then call RestoreScreenBufferAsync(0) to dismiss it — no need to re-stream the background.
Mode 3 Example — 3×3 Overworld Tiles
Mode 3 expands every map byte into a 3×3 character grid (24×24 px). The bank is 9 parallel pages (2.25 KB), so a single 256-id bank gives you 256 large, distinct tiles. With a 13×8 map you cover the entire 39×24 character viewport (104 map bytes for ~768 cells — nearly 8× compression).
// 9-page bank at $4000. Each page = one cell position in the 3x3 grid.
const ushort bankBase = 0x4000;
var bank = new byte[9 * 256];
// Slot layout (0-based row-major): 0 1 2 / 3 4 5 / 6 7 8
// Tile 0 = "stone block" - all 9 cells the same brick char ($A0)
for (int slot = 0; slot < 9; slot++)
bank[slot * 256 + 0] = 0xA0;
// Tile 1 = "torch" - mostly stone with a torch char in the centre
for (int slot = 0; slot < 9; slot++)
bank[slot * 256 + 1] = 0xA0;
bank[4 * 256 + 1] = 0x57; // centre slot = torch glyph
await client.StoreMemoryLargeCheckedAsync(bankBase, bank);
// 13x8 map = 39x24 chars in mode 3.
const ushort mapAddr = 0x6000;
var map = new byte[13 * 8];
// ... populate map with tile ids 0/1/... ...
await client.StoreMemoryLargeCheckedAsync(mapAddr, map);
await client.DrawMetatileAsync(
mode: 3,
mapAddr: mapAddr,
mapW: 13, mapH: 8,
metaHi: 0x40,
targetAddr: 0x0400, stride: 40,
winW: 39, winH: 24,
x: 0, y: 0,
offX: 0, offY: 0,
fillChar: 32,
timeout: TimeSpan.FromSeconds(2));
Colour-RAM Companion Pass
Every D render can drive colour RAM in lockstep with the character pass. The mode is selected by colorMode:
| Value | Mode | Behaviour |
|---|---|---|
0 |
NONE | Skip colour RAM entirely. Use this when you've already painted the colour grid (e.g. via FillColorBlockAsync) and only want to refresh characters. |
1 |
FILL | Every cell rendered (in-bounds and edge fill) is written with colorFill. Cheapest option that still touches colour RAM. |
2 |
MAP |
Mode 1: a parallel per-cell colour map at colorSrcAddr (same dimensions as the character map).Modes 2/3: a single 256-byte colour-per-tile-id table at colorSrcAddr — every cell of every metatile of id N is painted with peek(colorSrcAddr + N). The whole table is just 256 bytes.
|
3 |
PERCELL |
Mode 2 only: a 1 KB page-aligned bank of 4 parallel slot pages indexed by tile id, mirroring the metatile char bank. Each child cell of every metatile id can have its own colour. colorSrcAddr low byte must be $00; only the page (high byte) is used.Mode 1: identical to MAP (parallel per-cell colour map).Mode 3: falls back to MAP semantics (one colour per tile id) — for fully arbitrary per-cell colour at 3×3, use mode 1 with a parallel colour map instead.
|
Example: per-tile-id colour table for mode 2.
// One byte per tile id; colour is applied to all 4 corners of that tile.
const ushort tileColors = 0x6400;
var palette = new byte[256];
palette[0] = (byte)Rift64Color.LightGreen; // "grass"
palette[1] = (byte)Rift64Color.Brown; // "brick"
palette[2] = (byte)Rift64Color.Blue; // "water"
await client.StoreMemoryLargeCheckedAsync(tileColors, palette);
await client.DrawMetatileAsync(
mode: 2,
mapAddr: mapAddr, mapW: 20, mapH: 12,
metaHi: 0x40,
targetAddr: 0x0400, stride: 40,
winW: 40, winH: 24, x: 0, y: 0,
offX: 0, offY: 0,
colorMode: 2, // MAP
colorTgtAddr: 0xD800,
colorSrcAddr: tileColors,
timeout: TimeSpan.FromSeconds(2));
Example: per-cell colour for mode 2 (PERCELL).
Each metatile id gets four independent colour-RAM nibbles — one per child cell — in slot order (TL, TR, BL, BR). The SDK helper BuildMode2PerCellColorBank assembles the 1 KB page-aligned bank for you in the exact layout the renderer expects.
// Per-tile palette: (TopLeft, TopRight, BottomLeft, BottomRight).
var palette = new (byte TL, byte TR, byte BL, byte BR)[]
{
(1, 2, 3, 4), // tile 0: white red cyan purple
(5, 6, 7, 8), // tile 1: green blue yellow orange
(9, 10, 11, 12), // tile 2: brown lt-red dk-grey mid-grey
};
var bank = Rift64ProtocolClient.BuildMode2PerCellColorBank(palette, defaultColor: 14);
// Bank is 1 KB; address must be page-aligned ($XX00).
const ushort PERCELL_BANK = 0x4800;
await client.StoreMemoryLargeCheckedAsync(PERCELL_BANK, bank);
await client.DrawMetatileAsync(
mode: 2,
mapAddr: mapAddr, mapW: 20, mapH: 12,
metaHi: 0x40,
targetAddr: 0x0400, stride: 40,
winW: 40, winH: 24, x: 0, y: 0,
offX: 0, offY: 0,
color: MetatileColorMode.MapPerCell,
colorSrcAddr: PERCELL_BANK,
colorTgtAddr: 0xD800);
Tips, Pitfalls & Performance Notes
- Page alignment is mandatory. The metatile bank's low byte must be
$00. The renderer indexes each slot with absolute,Y addressing — only the high byte (metaHi) is sent over the wire. - Bank sizes: mode 2 needs 1 KB (4 pages); mode 3 needs 2.25 KB (9 pages). They sit comfortably in any free RAM block — common choices are
$4000,$5000,$C000. - Window in characters, not tiles.
winW/winHare character cells. To cover the full screen in mode 2 use 40×24 (skipping the bottom row keeps clear of the cursor); in mode 3 use 39×24 (39 = 13×3). - Edge clipping is automatic. If the requested window extends past the map's right or bottom edge the renderer pads those cells with
fillChar— you do not need to clip server-side. - Tile id 0 is just a tile. There is no "transparent" tile; if you want gaps, allocate an id whose slot pages are all spaces (
$20). - Pre-bake colour-per-id tables. For modes 2/3 the colour MAP table is indexed by tile id, not by cell — so you upload it once and forget about it. Re-upload only when your palette logically changes (day/night, damage flash, etc.).
- Reuse the bank across screens. Only the map, not the bank, needs to change between rooms / levels. Stream a new map into RAM and reissue
D— no bank re-upload required. - Tight scroll loops can starve the C64 renderer. The
Dpacket is ~20 hex bytes (~40 chars on the wire) plus the ACK round trip; that's ~12 ms at 38400 baud. Throttle your scroll to one packet per video frame (~50 / 60 Hz) to leave headroom for telemetry and audio. - Use
SaveScreenBufferAsyncfor overlays. Mode 1 is great for popups, but stash the background first (S0) and restore it (R0) on dismiss — the C64 does the copy locally with zero serial traffic.