There are several demos both packaged with devkitpro and in various tutorials that use sprites, but they almost all involve drawing one sprite (possibly multiple times) on screen. As I started trying to add to the demos, I found that I was having trouble drawing more than two sprites.

Several examples setup sprite OAM (Object Attribute Memory) as follows:
int spriteID = 0; // the sprite id
int spriteX = 0; // X position
int spriteY = 0; // Y position
sprites[0].attribute[0] = ATTR0_COLOR_16 | ATTR0_SQUARE | spriteX;
sprites[0].attribute[1] = ATTR1_SIZE_16 | spriteY;
sprites[0].attribute[2] = spriteID;

ATTR0_COLOR_16 specifies 16 color mode, ATTR0_SQUARE says the sprite is square (heigth = width), ATTR1_SIZE_16 makes the sprite 16x16.

And then sprite data is written like:
/* size of sprite in words (word is 16 bits) = 16 * 16 / 4
* 16-color mode = 4 bits per pixel (2 pixels per byte, or 4 per word)
* 16 pixel by 16 pixel sprite, 4 pixels per word = 16 * 16 / 4 bytes
int size = 64;
int pali = 2; // Palette index
for( int i = 0; i < size; i++ ){
SPRITE_GFX[ spriteID * size + i] = (pali << 12) | (pali << 8) | (pali << 4) | pali;

This just fills the entire sprite area with whatever color is defined at index 2 of the palette.

What was confusing me was the representation of sprites as tiles, how they are stored in sprite memory, the allocation of sprite IDs, and the way that sprite OAM uses sprite memory. If you are unclear on the meaning of tiles, check out Patater's tutorial.

Basically, tiles are 8 pixel by 8 pixel images, and all sprites are made up of them. What's important to understand here is that the sprite IDs reference the location of tiles in memory. If a sprite is larger than 8x8 then it uses more than one tile and covers a range of tile/sprite IDs. This means that two 16x16 sprites (each 4 tiles) stored in adjacent memory can't be allocated ID 0 and ID 1. The two images will overlap since the 16x16 sprite at ID 0 is defined as tiles 0 to 3, and the 16x16 sprite at ID 1 is defined as tiles 1 to 4.

You have to keep in mind the size of one pixel in memory and the dimensions of the sprite both when storing pixel data in sprite memory, and when allocating sprite IDs in sprite OAM.

In the example above (two 16-color 16x16 sprites):

  1. The first sprite is assigned ID 0 (8x8 tiles 0 to 3) and is stored in 128 bytes (16pix * 16pix / 2 pixels-per-byte) of sprite memory starting at index 0.

  2. The second sprite should be assigned ID 4 (8x8 tiles 4 to 7) and is stored in 128 bytes of sprite memory starting at index 128 + 1 (right after the first sprite).

NOTE: In the code above I offset by 64 rather than 128 because we are using word-aligned (16 bit) rather than byte-aligned (8 bit) addressing since SPRITE_GFX is an array of 16-bit values.