Simple8

A Complete Project, Step by Step

This is the chapter where everything comes together. We are going to write a real game, a breakout game, and run it on seven machines. The rules of the game will be written only once. The display, on the other hand, will be written for each machine, because each one draws in its own way. Doing this work is how you understand what gives a console or a home computer its personality, and why games of that era looked so different from one machine to the next.

One game, seven machines

Here is the result. Each image is a screenshot of this chapter's program, compiled by Simple8 and run in an emulator. The rules are the same everywhere, down to the pixel: only the way of drawing changes.

The breakout game on Commodore 64: bricks in a checkerboard of colors, gray walls
COMMODORE 648 × 8 pixel characters for the bricks, two sprites for the ball and the paddle. characterssprites View the sources →
The breakout game on PC Engine: brightly colored bricks on a midnight blue background
PC ENGINETiles, one palette per brick color, 16 × 16 and 32 × 16 sprites. tilessprites View the sources →
The breakout game on NES: red, orange and yellow bricks, last row green and blue
NESTiles drawn for the occasion, one palette per 32 × 32 pixel block. tilessprites View the sources →
The breakout game on Game Boy: bricks in four shades of gray on a white background
GAME BOYFour grays, and a 160 × 144 pixel screen: exactly the size of the playfield. tilesobjects View the sources →
The breakout game on Mega Drive: red, green, blue and pink bricks
MEGA DRIVEThe Game Boy's tiles, converted to 16 colors, loaded four times. tilessprites View the sources →
The breakout game on Amstrad CPC: red and orange bricks in a white frame
AMSTRAD CPCA bitmap screen and nothing else: everything is drawn pixel by pixel. bitmapsoftware sprites View the sources →
The breakout game on Amiga: bricks in a color gradient on a midnight blue background
AMIGAA bitmap filled by the Blitter, hardware sprites, a hand-drawn font. bitmapsprites View the sources →

Each version has its own source page, with all its files. The projects are also in the repository's Examples/ folder. Open one in Simple8 IDE, compile it, run it: with no joypad input, a demo plays on its own. Touch the joypad, and you take over.

MachineFolderSources
The rules, sharedExamples/common/CasseBriques/on every page
Commodore 64Examples/commodore/c64/CasseBriques_C64/view
PC EngineExamples/nec/pce/CasseBriques_PCE/view
NESExamples/nintendo/nes/CasseBriques_NES/view
Game BoyExamples/nintendo/dmg/CasseBriques_DMG/view
Mega DriveExamples/sega/megadrive/CasseBriques_MD/view
Amstrad CPCExamples/amstrad/cpc/CasseBriques_CPC/view
AmigaExamples/commodore/amiga/CasseBriques_Amiga/view

An example to learn from, not an optimization reference. These seven programs are not the best way to write a breakout game on each machine. A programmer aiming for performance on just one of them would make other choices: more assembly, routines tailored to that machine's video chip, data prepared in advance. The goal here is to teach: to show how you approach the design of a game depending on the architecture, and how you build it with Simple8. These examples are obviously not perfect, but they have the advantage of being short, readable, and comparable from one machine to the next.

The rules of the game

Before writing a single line, you decide what the game must do. This is the core of an Arkanoid, without the power-ups:

Anything you want to add later (power-ups, levels, sound) will build on this core.

The architecture: shared rules, one screen per machine

The trap would be to write seven games. Instead, you split the program into two units:

The shared Jeu unit calls the functions of the Ecran unit; each machine has its own Ecran.s8u file Jeu.s8u the rules, shared: 236 lines Ecran.placer(raquette, x, y)… Commodore 64 characters + sprites 102 lines PC Engine tiles + sprites 125 lines NES tiles + sprites 116 lines Game Boy tiles + objects 87 lines Mega Drive tiles + sprites 115 lines Amstrad CPC bitmap software sprites 111 lines Amiga bitmap + sprites 161 lines
Jeu only knows Ecran's functions; each machine writes them in its own way.

The Ecran unit's contract

Jeu calls eight functions of Ecran. Every Ecran.s8u must define them, with the same parameters. It is a contract, like an interface in Java: if one is missing, the compiler rejects the program (“Function 'message' not found”).

FunctionRole
init()prepare the screen: colors, patterns, walls, sprites
attendreImage()wait for the next frame: this is what sets the pace of the game
manette()return the keys pressed: Jeu.GAUCHE, Jeu.DROITE, Jeu.BOUTON
dessinerBriques()draw the 50 bricks
effacerBrique(c, l)erase the brick in column c, row l
placer(raquette, x, y)show the paddle and the ball in their places
afficherInfos()write the score and the lives
message(texte)write “BRAVO” or “PERDU” in the middle, 0 to erase it

The coordinates passed to Ecran are playfield coordinates, from 0 to 159 and from 0 to 143. Each machine places this playfield wherever it likes on its screen: it simply adds an offset (X0, Y0).

The main program

Each machine's main.s8p is tiny. Here is the Commodore 64 one:

// Breakout — Commodore 64
//
// The rules are in Examples/common/CasseBriques/Jeu.s8u, the same on every machine.
// Here, the C64 display: the bricks as characters, the ball and the paddle as sprites.
// Joystick in port 2: left, right, FIRE to launch the ball. Without a joystick, the demo plays.
architecture commodore.c64;

addlib event.irq;
addlib vic.base;
addlib vic.screentext;
addlib vic.sprite;
addlib io.base;

addunit "../../../common/CasseBriques/Jeu.s8u";   // the rules, shared
addunit "Ecran.s8u";                                // the C64 display

unit {
    main() {
        Jeu.jouer();
    }

    // the frame interrupt: it sets the pace of Ecran.attendreImage()
    interrupt -> irq {
    }
}

The libraries change from one machine to the next; the rest barely changes. The path ../../../common/ fetches the rules from the shared folder: there is only one copy of them.

Why is Jeu added before Ecran? Ecran reads variables from Jeu: Jeu.vies (lives), and the address of the score text, @Jeu.texteScore. With the current compiler, a unit can only take the address of a variable in a unit added before it. Function calls, on the other hand, are resolved in any order: Jeu can call Ecran, which is added after it.

The game loop

A video game is a loop: wait for the frame, read the joypad, move the world forward one step, show the result. Ours fits in a few lines:

// The game loop: called once by main(), never returns
void jouer() {
    Ecran.init();
    nouvellePartie();
    while (1) {
        Ecran.attendreImage();
        image(Ecran.manette());
        Ecran.placer((ubyte).raquetteX, (ubyte).balleX, (ubyte).balleY);
    }
}

And here is one frame of the game. The ball has two states: stuck to the paddle, or in play.

// One frame of the game
void image(ubyte touches) {
    bougerRaquette(touches);
    if (.collee == 1) {
        coller();
        if (.attente > 0) {
            .attente--;
        }
        if ((touches & .BOUTON) != 0 || .attente == 0) {
            .collee = 0;
            .dx = 1;
            .dy = -2;
        }
        return;
    }
    deplacerBalle();
    if (.balleY >= .HAUTEUR) {
        ballePerdue();
    }
}

All speeds are in pixels per frame: the paddle moves 2, the ball 1 or 2 in x and 2 in y. No decimals, no time calculations: the game is locked to the frame rate. A direct consequence: it runs a little faster on 60 frames per second machines (PC Engine, NES, Game Boy, Japanese or American Mega Drive) than on European 50 Hz machines (Commodore 64, Amstrad CPC, Amiga). Many games of that era had this flaw.

Waiting for the frame

attendreImage() is Ecran's most important function: it blocks until the vertical blank, the moment when the beam goes back up to the top of the screen (chapter 12). Each machine does it in its own way:

MachineattendreImage()
Commodore 64irq.waitFrame(), with an empty interrupt -> irq in main.s8p
PC Enginewait for Main.images to change; interrupt -> irq1 increments it every frame
NESppu.waitFrame(): the tiles changed during the frame are written at that moment
Game Boywait for Main.images (interrupt -> vblank), then copy the objects by DMA
Mega Drivevdp.waitVBlank()
Amstrad CPCwait for Main.images (interrupt -> int, called once per frame)
Amigaocs.frame()

The playfield and its grid

Why 160 × 144?

That is the Game Boy's screen, the smallest of the seven. A playfield of that size therefore fits everywhere, and the Game Boy shows it without cutting anything off.

The bricks are 16 × 8 pixels. That is no accident: 16 and 8 are powers of two (chapter 13). Dividing by 16 means shifting right by 4 bits; dividing by 8, shifting by 3. On a 6502 or a Z80, which have no division instruction, the difference is huge. And a brick is exactly two 8 × 8 tiles on the tile-based machines.

From pixel to brick

The bricks are stored in a 50-byte array, briques (bricks): 1 if the brick is there, 0 if it is broken. To find out which brick lies under a point (x, y) of the playfield, a small calculation is all it takes:

The 160 by 144 pixel playfield and its 50 bricks; the point (70, 37) falls in column 4 and row 2, brick number 24 0 32 64 96 128 160 16 24 32 40 48 56 (70, 37) Which brick is under the point (70, 37)? column = 70 >> 4 = 4 row = (37 - 16) >> 3 = 2 n = DEBUT_LIGNE[2] + 4 = 24 briques[24] is 1: hit No reading of the screen: two shifts, a table and an array cell. The same calculation on all seven machines.
The brick under a point is calculated, not searched for.
// The brick under the point (x, y), or 255 if there is none
ubyte briqueEn(int x, int y) {
    ubyte n;
    if (y < .HAUT_BRIQUES || y >= .BAS_BRIQUES || x < 0 || x >= .LARGEUR) {
        return 255;
    }
    .colonneTouchee = (ubyte)(x >> 4);                     // x / 16
    .ligneTouchee = (ubyte)((y - .HAUT_BRIQUES) >> 3);     // (y - 16) / 8
    n = (ubyte)(.DEBUT_LIGNE[.ligneTouchee] + .colonneTouchee);
    if (.briques[n] == 0) {
        return 255;
    }
    return n;
}

The multiplication row × 10 is replaced by a table, DEBUT_LIGNE[] = { 0, 10, 20, 30, 40 }: 10 is not a power of two, and reading a table is faster than multiplying. This is a recipe from chapter 13.

The ball

One axis at a time

The ball moves by dx in x and by dy in y. If you moved both at once, a ball arriving at the corner of a brick would raise a hard question: did it hit from the side (you must reverse dx) or from below (you must reverse dy)?

You avoid the question by moving first in x, checking what you hit, then in y, checking again. Each test concerns only one direction: a contact in x reverses dx, a contact in y reverses dy. It is simple, and in a breakout game you can barely tell.

The leading edge

There is no need to test all 16 pixels of the ball. When it moves to the right, only its right edge can enter a brick; when it moves up, only its top edge. You test the two corners of that edge: a brick is at least 8 pixels in size, so it cannot slip between two corners 4 pixels apart.

The 4 by 4 pixel ball: in x, moving right, you test the two pixels of the right edge; in y, moving up, the two pixels of the top edge In x: the ball moves right brick? dx 2 pixels: top and bottom of the right edge In y: the ball moves up brick? dy 2 pixels: left and right of the top edge
Two pixels per axis, four per frame, whatever the machine.
// The ball: move in x, then in y, and each time look at what the leading edge
// of the ball touches
void deplacerBalle() {
    int x = .balleX + .dx;
    int y;
    int bord;

    // the left and right walls
    if (x < 0) {
        x = 0;
        .dx = -.dx;
    } else if (x > .LARGEUR - .BALLE) {
        x = .LARGEUR - .BALLE;
        .dx = -.dx;
    }
    // the bricks, in x: the left or right edge of the ball, top and bottom
    bord = x;
    if (.dx > 0) {
        bord = x + .BALLE - 1;
    }
    if (toucher(bord, .balleY) != 0 || toucher(bord, .balleY + .BALLE - 1) != 0) {
        .dx = -.dx;
        x = .balleX;
    }
    .balleX = x;

    // the ceiling
    y = .balleY + .dy;
    if (y < 0) {
        y = 0;
        .dy = -.dy;
    }
    // the bricks, in y: the top or bottom edge of the ball, left and right
    bord = y;
    if (.dy > 0) {
        bord = y + .BALLE - 1;
    }
    if (toucher(.balleX, bord) != 0 || toucher(.balleX + .BALLE - 1, bord) != 0) {
        .dy = -.dy;
        y = .balleY;
    }
    .balleY = y;

    rebondRaquette();
}

Notice the order: you compute the future position, you test it, and if the ball hits something, you leave it where it was and reverse its speed. The ball never enters a brick, and never gets stuck in a wall.

toucher() (hit) does all the work of a broken brick: remove it from the array, add the points, ask Ecran to erase it, and check whether any are left.

// Breaks the brick under the point (x, y) if there is one; returns 1 if the ball hit
ubyte toucher(int x, int y) {
    ubyte n = briqueEn(x, y);
    if (n == 255) {
        return 0;
    }
    .briques[n] = 0;
    .restantes--;
    .score = .score + .POINTS[.ligneTouchee];
    Ecran.effacerBrique(.colonneTouchee, .ligneTouchee);
    majInfos();
    if (.restantes == 0) {
        gagne();
    }
    return 1;
}

Bouncing off the paddle

If the ball always left at the same angle, the player could not aim. So the paddle is cut into four zones of 6 pixels: the closer to the edge the ball hits, the steeper the angle it leaves at.

The 24-pixel paddle is cut into 4 zones of 6 pixels; depending on the zone hit, the ball leaves with dx = -2, -1, +1 or +2 dx = −2 0 to 5 dx = −1 6 to 11 dx = +1 12 to 17 dx = +2 18 to 23 paddle middle of the ball dy = −2 in every case
Where the ball hits sets the angle.
// The paddle: the ball is moving down and overlaps the paddle
void rebondRaquette() {
    int zone;
    if (.dy < 0) {
        return;
    }
    if (.balleY + .BALLE < .RAQUETTE_Y || .balleY > .RAQUETTE_Y + .RAQUETTE_H) {
        return;
    }
    if (.balleX + .BALLE <= .raquetteX || .balleX >= .raquetteX + .RAQUETTE_L) {
        return;
    }
    // where the ball hits sets the angle: the middle of the ball on one of the paddle's
    // 4 zones of 6 pixels. Three comparisons, no division by 6
    zone = .balleX + 2 - .raquetteX;
    if (zone < 6) {
        .dx = -2;
    } else if (zone < 12) {
        .dx = -1;
    } else if (zone < 18) {
        .dx = 1;
    } else {
        .dx = 2;
    }
    .dy = -2;
    .balleY = .RAQUETTE_Y - .BALLE;
    .visee++;
    if (.visee == 6) {
        .visee = 0;
    }
}

Three comparisons are enough: no need to divide by 6. The test at the start (.dy < 0) prevents a ball that is already going up from bouncing a second time off the paddle.

The demo

A demo that always puts the ball at the same spot on the paddle always sends it back at the same angle. While this chapter was being developed, before the current bounce, that was enough for the ball to go around in circles in an area that was already empty: the score stopped moving. So ours aims at a different spot on the paddle at each bounce, taken from a table:

// The paddle: the keys, or the demo when nobody is playing
void bougerRaquette(ubyte touches) {
    if (touches != 0) {
        .inactif = 0;
    } else if (.inactif < 255) {
        .inactif++;
    }
    if (.inactif > 120) {
        // the demo: the paddle follows the ball
        int cible = .balleX + 2 - .VISEES[.visee];
        if (.raquetteX + 12 < cible - 1) {
            touches = .DROITE;
        } else if (.raquetteX + 12 > cible + 1) {
            touches = .GAUCHE;
        }
    }
    if ((touches & .GAUCHE) != 0 && .raquetteX > 0) {
        .raquetteX = .raquetteX - 2;
    }
    if ((touches & .DROITE) != 0 && .raquetteX < .LARGEUR - .RAQUETTE_L) {
        .raquetteX = .raquetteX + 2;
    }
}

With VISEES[] = { -9, 6, -3, 9, -6, 3 } (aim points), the paddle comes in sometimes to the left, sometimes to the right of the ball: successive bounces go off in different directions. A demo doesn't need to be smart, just varied.

Three ways to detect a collision

Detecting that a ball hits a brick is the central question of a breakout game. There are three main methods, and all of them were used back in the day.

1. Calculating in a grid

This is our game's method. The program knows the position of every brick in an array, and calculates what lies under a point. It never reads the screen:

Its limit: it assumes the scenery is laid out on a grid. For free-form scenery, you need something else.

2. Reading the screen

On a bitmap screen, you can look at the color of the pixel the ball is moving toward. On the Amstrad CPC, gfx.getPixel returns the pen of a pixel:

// the pen of the pixel just above the ball: 0, nothing; otherwise, a brick or a wall
ubyte devant = gfx.getPixel(.X0 + x + 1, .Y0 + y - 1);

It is tempting, but the color does not tell you which brick is hit: to erase it, you have to calculate its column and row anyway. Each thing also needs its own color (an orange brick and orange text look the same), and reading a pixel costs more than reading an array cell.

On tile-based machines, the equivalent would be reading the tile map. But it lives in video memory, which the processor can only read through the video chip's ports (PC Engine, NES, Mega Drive), sometimes only during the vertical blank (NES, Game Boy). The solution back then was to keep a copy of the map in RAM… which is exactly our briques array.

3. Hardware collisions

Some video chips detect contacts between sprites, or between a sprite and the background, on their own. On the Commodore 64, the VIC-II sets a bit to 1 for each sprite in $D01E (contact with another sprite) and in $D01F (contact with the background):

ubyte contact = readMem(0xD01F);        // one bit per sprite; reading resets it to 0
if ((contact & (1 << .spriteBalle)) != 0) {
    // the ball hit a background character
}
MachineHardware collisions
Commodore 64sprite against sprite, sprite against background, one bit per sprite
Amigasprites and screen planes, in pairs (CLXDAT register); sprite.hit() compares two bobs pixel-exact with the Blitter
PC Enginesprite 0 against the other sprites, a single bit
Mega Drivetwo overlapping sprites, a single bit, without saying which ones
NES, Game Boy, Amstrad CPCnothing usable for the game

They are pixel-exact, and free for the processor. But they arrive after the fact: the chip reports an overlap once the frame has been drawn, so when the ball is already inside the brick. They don't tell you which brick is hit either, and they don't exist on every machine.

Which one to choose?

MethodProsCons
The gridfast, predicts the contact, tells which brick, the same everywherescenery laid out on a grid
Reading the screenworks for any shapedoesn't tell which object; video memory slow or out of reach
The hardwarepixel-exact, free for the processorafter the fact, rarely detailed, not everywhere

In a real game, they are often combined: the grid for the scenery, overlapping rectangles between objects, and the pixel-exact test (hardware or Blitter) only when the rectangles touch.

Tiles, bitmaps and sprites

The rules are written. Now comes the drawing, and this is where the machines go their separate ways.

Tiles

On a tile-based machine (Commodore 64 in text mode, PC Engine, NES, Game Boy, Mega Drive), the screen is not a grid of pixels but a grid of 8 × 8 cells. Each cell holds the number of a pattern, drawn once and for all in video memory. To show a 16 × 8 brick, you write two numbers. To erase it, two others. The video chip does the rest every frame.

It is cheap, and perfectly suited to grid-based scenery like a wall of bricks. The constraint: everything in the background has to fall on the cell grid.

Bitmaps

On a bitmap machine (Amstrad CPC, Amiga), every pixel on the screen has its own place in memory. You can draw anything, anywhere: curves, polygons, gradients. But every pixel has a cost: a 16 × 8 brick means 128 pixels to write.

Bytes written to erase a brick: 2 to 4 with tiles, 32 on the Amstrad CPC and 64 on the Amiga in bitmap mode Commodore 64 4 bytes 2 characters + their 2 colors PC Engine 4 bytes 2 BAT cells (2 words) NES 2 bytes 2 nametable cells Game Boy 2 bytes 2 background map cells Mega Drive 4 bytes 2 plane A cells (2 words) Amstrad CPC 32 bytes 16 × 8 pixels, 4 pixels per byte Amiga 64 bytes 16 × 8 pixels × 4 planes, by the Blitter
What the program writes to video memory to erase a brick.

The Amiga makes up for it with its Blitter, a chip that fills and copies rectangles while the processor does something else. The Amstrad CPC only has its Z80: the processor writes every single byte.

Hardware sprites

A sprite is a small picture that the video chip overlays on the screen, at the position you give it. To move it, you change two numbers. Nothing to erase, nothing to redraw, and the background underneath stays intact. It is ideal for things that move: the ball and the paddle.

Each machine has its limits: 8 sprites of 24 × 21 on the Commodore 64, 64 sprites of 8 × 8 on the NES, 40 objects on the Game Boy, 64 sprites up to 32 × 64 on the PC Engine, 80 on the Mega Drive, 8 sprites 16 pixels wide on the Amiga. A 24-pixel paddle therefore takes one sprite on the Commodore 64, three on the NES and Game Boy, two on the Amiga.

Software sprites

The Amstrad CPC has no sprites. You have to imitate them: every frame, erase the old position, then draw the new one. This is what is called a software sprite.

A hardware sprite moves by changing its position; a software sprite must be erased at its old position, then redrawn at the new one Hardware sprite x = 130, y = 40 you change two numbers: the video chip draws Software sprite (Amstrad CPC) 1. erase 2. draw the program repaints the old spot, then draws the new one
The same move, done by the video chip or by the program.

On a black background, erasing means repainting in black. On a drawn background, you would first have to save what is under the sprite, to put it back afterward: that is what the Amiga's “bobs” do (video.sprite), with the help of the Blitter.

Machine by machine

Each Ecran.s8u is between 87 and 161 lines long. Here is, for each machine, what guided its choices.

Commodore 64

The breakout game on Commodore 64
Bricks
two reverse-video spaces: two solid characters
Colors
one per cell (color memory): a checkerboard of two shades per row
Ball, paddle
two 24 × 21 hardware sprites
Text
the Commodore font, directly
Sources
all the project's files

The C64's text mode is already a tile machine: 40 × 25 cells, each with its own color. A space in reverse video is a solid square in the text color. So a brick is two reversed spaces, without drawing a single pattern:

// A brick = two reverse-video spaces, in the color of its row
void dessinerBriques() {
    for (ubyte l = 0; l < 5; l++) {
        for (ubyte c = 0; c < 10; c++) {
            if (((c + l) & 1) == 0) {
                vic.setFontColor(.COULEUR_A[l]);
            } else {
                vic.setFontColor(.COULEUR_B[l]);
            }
            vic.setCursor((ubyte)(.COL0 + c * 2), (ubyte)(.LIG0 + 2 + l));
            vic.setInvertOn();
            vic.print("  ");
            vic.setInvertOff();
        }
    }
}

The ball and the paddle are sprites: two calls to moveSprite per frame, and that's it.

// The sprites follow the game: two calls, nothing to erase
void placer(ubyte raquette, ubyte x, ubyte y) {
    sprite.moveSprite(.spriteRaquette, .X0 + raquette, .Y0 + Jeu.RAQUETTE_Y);
    sprite.moveSprite(.spriteBalle, .X0 + x, .Y0 + y);
}

PC Engine

The breakout game on PC Engine
Bricks
solid tiles in the BAT
Colors
one tile palette per brick color (1 to 10), one for the walls
Ball, paddle
two sprites, 16 × 16 and 32 × 16
Text
a font loaded into video memory, from a resource
Sources
all the project's files

The PC Engine has 16 palettes of 16 colors for tiles: you can afford one palette per brick color, and choose a brick's color by choosing its palette. It has no font in ROM: police.bin is added by addResource and copied into video memory at startup.

Its sprites read their patterns from video memory: you copy them there, then create the sprites by giving the pattern's address and their size.

// the sprite patterns, copied into video memory
vdc.writeToVRAM(@.MOTIF_BALLE, .VRAM_BALLE, 128);
vdc.writeToVRAM(@.MOTIF_RAQUETTE, .VRAM_RAQUETTE, 256);
sprite.initSprite();
sprite.setSpritePriority(sprite.SPRITE_PRIORITY_FG);
sprite.setSpriteSize(sprite.SPRITE_16x16);
sprite.setSpritePalette(0);
.spriteBalle = sprite.createSprite(0, -64, .VRAM_BALLE);
sprite.setSpriteSize(sprite.SPRITE_32x16);
sprite.setSpritePalette(1);
.spriteRaquette = sprite.createSprite(0, -64, .VRAM_RAQUETTE);

NES

The breakout game on NES
Bricks
six drawn tiles: two brick halves in three colors
Colors
one 3-color palette per 32 × 32 pixel block
Ball, paddle
4 sprites of 8 × 8: 1 for the ball, 3 for the paddle
Text
the font from ppu.screentext
Sources
all the project's files

The NES imposes the toughest constraint: the palette is not chosen per cell, but per 32 × 32 pixel block (4 × 4 cells). So four rows of bricks share the same 3-color palette. The trick: put the color in the tile itself (one tile in color 1, one in color 2, one in color 3), and align the playfield on the blocks so that the walls get their own.

// one palette per 32 x 32 pixel block: the brick blocks
for (byte bx = 2; bx < 7; bx++) {
    ppu.setAreaPalette(bx, 2, 1);
    ppu.setAreaPalette(bx, 3, 2);
}

Another constraint: you can only write to the tile map during the vertical blank. To place the wall's 100 tiles in one go, dessinerBriques() turns the screen off (ppu.off()); during the game, ppu.putTile queues the tiles, and ppu.waitFrame() writes them at the vertical blank.

Game Boy

The breakout game on Game Boy
Bricks
six drawn tiles, one gray per row
Colors
4 grays in all: the white background, the black text, two grays
Ball, paddle
4 objects of 8 × 8
Text
a font generated by tools/police.py (Police.s8u)
Sources
all the project's files

The screen is exactly 160 × 144 pixels: the playfield takes up the whole screen, and the score is written on the first line. With 4 grays, the rows are told apart by their shade, nothing more.

Here, the objects (the Game Boy's sprites) are not written directly: the program fills a copy of their table in RAM, and DMA copies it over in one go during the vertical blank.

// One object in the copy of the table: y + 16, x + 8, tile, attributes
void objet(ubyte n, ubyte x, ubyte y, ubyte tuile) {
    ubyte i = (ubyte)(n * 4);
    oam.shadow[i] = (ubyte)(y + 16);
    oam.shadow[i + 1] = (ubyte)(x + 8);
    oam.shadow[i + 2] = tuile;
    oam.shadow[i + 3] = 0;
}

// Wait for the vertical blank, then copy the objects there by DMA
void attendreImage() {
    ubyte avant = Main.images;
    while (Main.images == avant) {
    }
    oam.flush();
}

Mega Drive

The breakout game on Mega Drive
Bricks
the Game Boy's tiles, converted to 16 colors
Colors
4 sets of 3 colors in palette 0
Ball, paddle
two sprites: 8 × 8, and 24 × 8 (three tiles)
Text
the font from vdp.loadFont
Sources
all the project's files

The Mega Drive has 16-color tiles (4 bits per pixel). Rather than redrawing the bricks, we reuse the Game Boy's tiles (2 bits per pixel): vdp.loadTiles2bpp converts them by shifting their colors. The same 6 tiles, loaded 4 times with an offset of 0, 4, 8 and 12, give 4 sets of colors:

for (ubyte jeu = 0; jeu < 4; jeu++) {
    vdp.loadTiles2bpp(@.TUILES_BRIQUES, (uint)(.BRIQUES + jeu * 6), 6, (ubyte)(jeu * 4), 0);
}

Its sprites form a linked list: each one points to the next. The ball (0) leads to the paddle (1), which ends the list. A Mega Drive sprite can be up to 32 × 32 pixels: the paddle fits in a single one.

// Two linked sprites: the ball (0), then the paddle (1), the end of the list
void placer(ubyte raquette, ubyte x, ubyte y) {
    vdp.setSprite(0, .X0 + x, .Y0 + y, 0, 1, vdp.cell(.BALLE, 2, 0));
    vdp.setSprite(1, .X0 + raquette, .Y0 + Jeu.RAQUETTE_Y, 8, 0, vdp.cell(.RAQUETTE, 2, 0));
}

Amstrad CPC

The breakout game on Amstrad CPC
Bricks
solid rectangles, gfx.setFillRect
Colors
4 pens in mode 1: black, red, orange, white
Ball, paddle
software sprites: erase, then redraw
Text
gfx.setText, on an area first repainted in black
Sources
all the project's files

The CPC is the most bare-bones machine: a bitmap screen, and that's all. In mode 1 (320 × 200, 4 colors), once black and white are taken, two colors are left for the bricks. The ball and the paddle are software sprites, and they are redrawn only if they have moved:

// Software sprites: erase the old position, draw the new one
void placer(ubyte raquette, ubyte x, ubyte y) {
    int ry = .Y0 + Jeu.RAQUETTE_Y;
    if (raquette != .ancienneRaquette) {
        if (.ancienneRaquette >= 0) {
            gfx.setPen(.NOIR);
            gfx.setFillRect(.X0 + .ancienneRaquette, ry, .X0 + .ancienneRaquette + 23, ry + 3);
        }
        gfx.setPen(.BLANC);
        gfx.setFillRect(.X0 + raquette, ry, .X0 + raquette + 23, ry + 3);
        .ancienneRaquette = raquette;
    }
    if (x != .ancienneBalleX || y != .ancienneBalleY) {
        if (.ancienneBalleX >= 0) {
            gfx.setPen(.NOIR);
            gfx.setFillRect(.X0 + .ancienneBalleX, .Y0 + .ancienneBalleY,
                            .X0 + .ancienneBalleX + 3, .Y0 + .ancienneBalleY + 3);
        }
        if (y < Jeu.HAUTEUR) {
            gfx.setPen(.BLANC);
            gfx.setFillRect(.X0 + x, .Y0 + y, .X0 + x + 3, .Y0 + y + 3);
        }
        .ancienneBalleX = x;
        .ancienneBalleY = y;
    }
}

Even the text needs care: in bitmap mode, writing a digit over another one overlays the two. So the score area is repainted in black before it is written.

Amiga

The breakout game on Amiga
Bricks
rectangles laid down by the Blitter, with a highlight line
Colors
16 colors (4 planes), each chosen from 4,096
Ball, paddle
three hardware sprites: 1 for the ball, 2 for the paddle
Text
a 3 × 5 pixel font, drawn with small squares
Sources
all the project's files

The Amiga brings together the best of both worlds: a bitmap screen that the Blitter fills quickly, and real sprites. With 16 colors, each brick can have a lighter line at the top, a highlight. The sprites are described by a list in memory: their lines are written once, at startup, and then only their two control words (their position) change every frame.

// The sprites: only their control words
void placer(ubyte raquette, ubyte x, ubyte y) {
    int rx = .X0 + raquette;
    ocs.spritePosition(ocs.sprite(0, 0), .X0 + x, (uint)(.Y0 + y), 4, 0);
    ocs.spritePosition(ocs.sprite(0, 2), rx, (uint)(.Y0 + Jeu.RAQUETTE_Y), 4, 0);
    ocs.spritePosition(ocs.sprite(0, 3), rx + 16, (uint)(.Y0 + Jeu.RAQUETTE_Y), 4, 0);
}

Without the operating system, the Amiga has no font at hand. No matter: 5 rows of 3 pixels per character, each pixel drawn as a 2 × 2 square:

// One character of the font, in 2 x 2 squares; a space draws nothing
void lettre(int x, int y, ubyte c) {
    if (c < '0' || c > 'Z') {
        return;
    }
    uint g = (uint)(c - '0') * 5;
    for (ubyte r = 0; r < 5; r++) {
        ubyte bits = .POLICE[g + r];
        for (ubyte k = 0; k < 3; k++) {
            if ((bits & (4 >> k)) != 0) {
                bitmap.rect(x + (int)k * 2, y + (int)r * 2, 2, 2, .TEXTE);
            }
        }
    }
}

The summary

MachineBricksBall and paddleEcran.s8uProgram
Commodore 64characters2 sprites102 lines5.4 KB (.prg)
PC Enginetiles2 sprites125 lines16 KB HuCard
NEStiles4 sprites116 lines32 KB cartridge
Game Boytiles4 objects87 lines32 KB cartridge
Mega Drivetiles2 sprites115 lines128 KB cartridge
Amstrad CPCbitmapsoftware sprites111 lines7.9 KB (on disk)
Amigabitmap (Blitter)3 sprites161 lines67 KB (ADF disk)

The rules, for their part, take 236 lines, written only once. A cartridge has a standard size: the game itself takes up much less.

What this project shows. Simple8 doesn't hide the machines: on each one, we used what it does best: tiles where there are tiles, sprites where there are sprites, the Blitter on the Amiga. What Simple8 brings is a common language for the part that doesn't depend on the hardware. And in a game, that is often the biggest part.

Going further

The game is deliberately minimal. Here are some ideas, from the simplest to the most ambitious:

Key points

Your turn

  1. Change the values of POINTS[] and VISEES[], and watch the demo play differently on two machines.
    Show solutionHide solution

    Both tables are in Jeu.s8u: a single change applies to all seven machines. We tried three settings, compiled for the Commodore 64 and the PC Engine, and let the demo play on its own for three minutes in the test kit's emulators.

    POINTS[]: the bottom rows are worth more

    const uint POINTS[] = { 10, 20, 30, 40, 50 };        // the bottom rows are worth more

    The demo plays exactly the same game: the first wall falls at 98.1 s on the C64 and 81.6 s on the PC Engine, just as with the original values. POINTS[] is only used for the score. Only the running score changes (3,200 instead of 3,040 on the C64 after 3 minutes); a complete wall is still worth 1,500 points, since the sum of the five values is the same.

    VISEES[]: always aim at the middle

    const byte VISEES[] = { 0, 0, 0, 0, 0, 0 };

    The paddle sits under the ball, which always leaves at the same small angle. The start is slower (830 points at 40 s on the C64, versus 1,170), but the end is much faster: the first wall falls at 69.1 s instead of 98.1 s on the C64, and at 57.5 s instead of 81.6 s on the PC Engine. With the original values, the last bricks take a long time to find; here, the ball reaches them better. So the demo is less varied, but not necessarily less effective: you have to measure it to know.

    VISEES[]: aim at the ends of the paddle

    const byte VISEES[] = { -11, 11, -11, 11, -11, 11 };

    This time, the demo keeps losing: a life every 3 to 7 seconds on both machines, and almost no points. The ball only touches the very last pixels of the paddle, and the target jumps 22 pixels from one bounce to the next; but the paddle moves 2 pixels per frame, no faster than a ball launched at an angle: it often arrives too late.

    Two rules to remember. An aim point must stay within the paddle: between -11 and 11, since the paddle is 24 pixels wide. And the table must keep 6 values: rebondRaquette() resets visee to 0 when it reaches 6. For another size, you have to change that 6 too.

    Three runs of the demo on the C64 at the same moment
    The demo on the C64 (VICE), about 40 seconds after startup: the original aim points, then all set to 0, then at the ends of the paddle.
  2. Add solid bricks that take two hits.
    Show solutionHide solution

    The briques array no longer just says whether a brick is there: it counts the hits still needed to break it. 0, it is broken; 1, a normal brick; 2, a solid brick. The rest of Jeu keeps working unchanged: briqueEn() still tests briques[n] == 0, and restantes (remaining) still counts bricks, not hits. Here, the top row is solid; a small table gives the solidity of each row.

    In Jeu.s8u

    const ubyte SOLIDITE[] = { 2, 1, 1, 1, 1 };          // the hits needed to break a brick

    The two loops that filled the wall (in nouvellePartie() and in gagne()) put 1 everywhere. They are replaced by a call to a new function, remplirMur():

    // A fresh wall: each brick gets the solidity of its row
    void remplirMur() {
        ubyte n = 0;
        for (ubyte l = 0; l < .LIGNES; l++) {
            for (ubyte c = 0; c < .COLONNES; c++) {
                .briques[n] = .SOLIDITE[l];
                n++;
            }
        }
        .restantes = 50;
    }

    In toucher(), you take away one hit. If any are left, the brick stays standing: no points, the ball bounces (we return 1), and Ecran changes its color.

    ubyte toucher(int x, int y) {
        ubyte n = briqueEn(x, y);
        if (n == 255) {
            return 0;
        }
        .briques[n]--;                  // one hit less
        if (.briques[n] != 0) {
            // a solid brick, still standing: it changes color, the ball bounces
            Ecran.fissurerBrique(.colonneTouchee, .ligneTouchee);
            return 1;
        }
        .restantes--;
        .score = .score + .POINTS[.ligneTouchee];
        Ecran.effacerBrique(.colonneTouchee, .ligneTouchee);
        majInfos();
        if (.restantes == 0) {
            gagne();
        }
        return 1;
    }

    Jeu now calls a ninth function of Ecran, fissurerBrique(c, l) (crack brick). The contract grows: every Ecran.s8u must define it, otherwise that machine's program no longer compiles (Function 'fissurerBrique' not found !, checked on the Amstrad CPC version). dessinerBriques() must also draw the solid bricks in another color: it reads Jeu.briques, which is allowed, since Jeu is added before Ecran.

    On the Commodore 64: a solid brick is white; once hit, it takes back the color of its row.

    // A brick = two reverse-video spaces; a solid brick is white
    void dessinerBriques() {
        ubyte n = 0;
        for (ubyte l = 0; l < 5; l++) {
            for (ubyte c = 0; c < 10; c++) {
                if (Jeu.briques[n] == 2) {
                    brique(c, l, vic.WHITE);
                } else {
                    fissurerBrique(c, l);
                }
                n++;
            }
        }
    }
    
    // A brick in the color of its row, as a checkerboard
    void fissurerBrique(ubyte c, ubyte l) {
        if (((c + l) & 1) == 0) {
            brique(c, l, .COULEUR_A[l]);
        } else {
            brique(c, l, .COULEUR_B[l]);
        }
    }
    
    void brique(ubyte c, ubyte l, ubyte couleur) {
        vic.setFontColor(couleur);
        vic.setCursor((ubyte)(.COL0 + c * 2), (ubyte)(.LIG0 + 2 + l));
        vic.setInvertOn();
        vic.print("  ");
        vic.setInvertOff();
    }

    On the PC Engine: the same thing with one more palette, number 12 (the bricks use palettes 1 to 10, the walls palette 11), set in init():

    vce.setPalette(12, 1, RGB(7, 7, 7));    // the solid bricks: white
    // A brick = two solid tiles (reverse-video spaces), in the palette of its color;
    // a solid brick uses palette 12
    void dessinerBriques() {
        ubyte n = 0;
        for (ubyte l = 0; l < 5; l++) {
            for (ubyte c = 0; c < 10; c++) {
                if (Jeu.briques[n] == 2) {
                    brique(c, l, 12);
                } else {
                    fissurerBrique(c, l);
                }
                n++;
            }
        }
    }
    
    // A brick in the palette of its row, as a checkerboard
    void fissurerBrique(ubyte c, ubyte l) {
        brique(c, l, (ubyte)(1 + l * 2 + ((c + l) & 1)));
    }
    
    void brique(ubyte c, ubyte l, ubyte numero) {
        vdc.setFontColor(numero);
        vdc.setCursor((ubyte)(.COL0 + c * 2), (ubyte)(.LIG0 + 2 + l));
        vdc.setInvertOn();
        vdc.print("  ");
        vdc.setInvertOff();
    }

    Watch out for a trap: the parameter of brique() cannot be called palette. It is a reserved word in Simple8 (it is used for resources), and the compiler stops on a syntax error: Encountered " "palette" "palette "". So it is called numero (number).

    Both versions compile and play. In the demo, after 40 seconds, you can see top bricks that have turned red or pink again (hit once) and others that have disappeared (hit twice). The game lasts longer: after 4 minutes, the demo has not yet finished the first wall (1,450 points on both machines; one brick is left, already hit once), whereas it finishes it in 98 s on the C64 and 82 s on the PC Engine without solid bricks.

    Three moments of the demo on PC Engine, with a row of white bricks at the top
    On the PC Engine, after 5 s, 40 s and 4 minutes: the white row is solid; a brick hit once takes back the color of its row.
    The demo on C64 with a row of white bricks
    On the Commodore 64 (VICE), about 40 seconds after startup.
  3. On the Amstrad CPC, replace the grid test with gfx.getPixel. What do you still have to calculate to erase the right brick?
    Show solutionHide solution

    Jeu must not know anything about the machine: it cannot call gfx.getPixel itself. So reading the screen goes into the CPC's Ecran, as a small function that says whether a point of the playfield has a brick's color. We only look at the two brick pens: the white of the walls, the paddle, the ball and the text is not a brick.

    // 1 if the point (x, y) of the playfield has a brick's color: we read the screen
    ubyte briqueVisible(int x, int y) {
        ubyte pen = gfx.getPixel(.X0 + x, .Y0 + y);
        if (pen == .BRIQUE_A || pen == .BRIQUE_B) {
            return 1;
        }
        return 0;
    }

    In briqueEn(), this read replaces the test of the briques[n] cell. The test of the brick area at the start is kept: it only costs four comparisons, and avoids reading the screen when the ball is far from the bricks, which is almost always.

    // The brick under the point (x, y), or 255 if there is none
    ubyte briqueEn(int x, int y) {
        ubyte n;
        if (y < .HAUT_BRIQUES || y >= .BAS_BRIQUES || x < 0 || x >= .LARGEUR) {
            return 255;
        }
        // the screen tells whether there is a brick...
        if (Ecran.briqueVisible(x, y) == 0) {
            return 255;
        }
        // ... but not which one: its column and row are still calculated
        .colonneTouchee = (ubyte)(x >> 4);                     // x / 16
        .ligneTouchee = (ubyte)((y - .HAUT_BRIQUES) >> 3);     // (y - 16) / 8
        n = (ubyte)(.DEBUT_LIGNE[.ligneTouchee] + .colonneTouchee);
        return n;
    }

    Here is the answer to the question: the color says there is a brick, not which one. You still have to calculate its column (x >> 4) and its row ((y - 16) >> 3). The column and row are used by Ecran.effacerBrique(c, l), which repaints the right brick in black, and the row gives the points (POINTS[ligneTouchee]). The number n is still used by toucher(), which sets the cell to 0: the array is no longer read for collisions, but it stays accurate.

    This version modifies Jeu.s8u and calls a function that only the CPC's Ecran has: it would no longer compile for the other machines. That is the price of this method: it ties the rules to one machine's screen.

    Checked in the test kit's emulator: the demo plays normally and finishes its first wall in 108.5 s (104.3 s with the grid: the trajectory changes a little, probably because the one-pixel gap between two bricks is black, whereas the grid counts it as part of the brick). And reading costs more: a call to briqueEn() that passes the area test takes 2,136 T-states on average instead of 1,192 with the grid, measured over the first 30 seconds.

    The breakout game demo on Amstrad CPC, with getPixel
    The version with gfx.getPixel, after 30 seconds of demo.
  4. Pick a machine and add a sound on each bounce, without touching Jeu.s8u beyond the call itself.
    Show solutionHide solution

    The Commodore 64 is the simplest machine for a “beep”: its sound chip, the SID, plays a note by itself once it is set up, and the sid.base library provides everything you need. You just give it a waveform, an envelope (how the sound rises, holds and fades), a note, then open and close the voice's gate.

    In Jeu.s8u, only one line is added, in rebondRaquette(), when the ball bounces back:

    .dy = -2;
    .balleY = .RAQUETTE_Y - .BALLE;
    Ecran.bip();                    // the bounce sound: each machine plays it its own way
    .visee++;

    Everything else is in the C64's Ecran. In main.s8p, the SID library:

    addlib sid.base;                  // sound: the SID

    In Ecran.s8u, one variable, the end of init(), and two functions:

    ubyte dureeBip;             // frames before the beep ends, 0: silence
    // …
    void init() {
        // …
        // sound: SID voice 0, a square wave that starts right away
        sid.init();
        sid.setWaveform(0, sid.PULSE);
        sid.setPulseWidth(0, 0x800);
        sid.setEnvelope(0, 0x00, 0xF6);     // attack 0, decay 0, sustain 15, release 6
    }
    // …
    void attendreImage() {
        irq.waitFrame();
        if (.dureeBip > 0) {
            .dureeBip--;
            if (.dureeBip == 0) {
                sid.noteOff(0);             // end of the beep: the sound fades (the release)
            }
        }
    }
    
    // A short beep: the note starts, attendreImage() stops it three frames later
    void bip() {
        sid.setNote(0, 60);                 // C-5
        sid.noteOn(0);
        .dureeBip = 3;
    }
    

    Why not close the gate right away in bip()? The SID only restarts a note when the gate goes from closed to open; the sound also has to last long enough to be heard. So bip() opens the gate, and attendreImage(), already called every frame, closes it three frames later. The sound then fades out smoothly (the release), and the gate is closed for the next bounce.

    Adding a function called by Jeu means extending the contract: every Ecran.s8u must now have a bip(), otherwise its machine no longer compiles (Function 'bip' not found !, obtained on the Amstrad CPC version). An empty function is enough on the machines that stay silent:

    // No sound on the CPC yet: the function exists, it does nothing
    void bip() {
    }

    Checked in two ways. In the test kit's emulator, which logs writes to the SID: in 60 seconds of demo, 28 bounces off the paddle and 28 openings of voice 0's gate ($D404 = $41), each closed 3 frames later ($D404 = $40, 0.058 s later). And in VICE, by recording the sound to a WAV file: a sound of about a tenth of a second at each bounce (at 2.96 s, 4.42 s, 5.88 s, 7.32 s…), with a frequency close to 520 Hz, that of C5 (523 Hz).

You now have everything you need to write your own games. Pick a machine, open Simple8 IDE, and dive in!