Nintendo 3DS homebrew development: build your own game

Nintendo 3DS homebrew development: build your own game

Fancy building your own homebrew game for the Nintendo 3DS? Good news: it is still entirely doable, and it is a genuinely fun project. In this tutorial I share everything I learnt while writing 2048 for Nintendo 3DS, a complete homebrew game with animations, audio, 13 languages and an achievement system, from the first line of code to a working installable .cia file.

See the finished result: 2048 for Nintendo 3DS, free to download, in .3dsx and .cia.

This guide covers the full 3DS homebrew development pipeline: 2D graphics with citro2d, NDSP audio, the touch screen, the RomFS file system, and CIA packaging. Everything is written in C99. No theory for the sake of it, only code that runs, taken straight from the 2048-3DS project.

Contents

  1. What a 3DS homebrew actually is
  2. Prerequisites: installing devkitARM and the 3DS toolchain
  3. Project architecture: separating game logic from rendering
  4. Working with the Nintendo 3DS dual screen
  5. 2D rendering with citro2d
  6. Input handling: D-pad, Circle Pad and touch screen
  7. 3DS homebrew audio: NDSP and PCM WAV files
  8. File systems: RomFS and the SD card
  9. Saving data to the SD card
  10. The 3DS Makefile: ARM cross-compilation with devkitARM
  11. Building a .3dsx file for the Homebrew Launcher
  12. Building an installable .cia file for the HOME menu
  13. Common pitfalls in 3DS homebrew development
  14. Conclusion and further resources

1. What is a Nintendo 3DS homebrew?

A 3DS homebrew is an unofficial application that runs on a Nintendo 3DS fitted with a custom firmware (CFW) such as Luma3DS. The 3DS homebrew scene lets anyone write and freely distribute games, emulators and utilities for the handheld.

The Nintendo 3DS runs an ARM11 (ARMv6K) processor clocked at 268 MHz alongside a PICA200 GPU, with one distinctive feature: two screens. The top screen is 400×240 pixels (800×240 in stereoscopic 3D) and the bottom screen, which is touch-sensitive, is 320×240 pixels.

3DS homebrew comes in two distribution formats:

  • .3dsx: the homebrew format launched from the Homebrew Launcher. No installation, it runs straight off the SD card.
  • .cia: an installable format that shows up in the 3DS HOME menu, with its own icon and animated banner. Requires a custom firmware (CFW).

For 2048-3DS I ship both formats, to reach as many players as possible. The 3DS homebrew community is still very much alive thanks to a solid tooling ecosystem: devkitARM (the toolchain), libctru (the system library), citro2d/citro3d (graphics). If you are after a good playground for learning embedded systems programming or retro game development, the 3DS is ideal.

2. Prerequisites: installing devkitARM and the 3DS toolchain

Before you can start writing a game for the Nintendo 3DS, you need the ARM cross-compilation toolchain and the console-specific libraries.

The devkitARM toolchain: the compiler for the Nintendo 3DS

devkitARM is the GCC cross-compilation toolchain for ARM processors, shipped by the devkitPro project. It bundles the compiler, the linker and the build tools you need to compile C code for the 3DS. Here is how to install it:

bash
# Install on Linux/WSL (recommended for 3DS development)
wget https://apt.devkitpro.org/install-devkitpro-pacman
chmod +x install-devkitpro-pacman
sudo ./install-devkitpro-pacman

# Install the 3DS development packages
sudo dkp-pacman -S 3ds-dev

# Required environment variable
export DEVKITARM=/opt/devkitpro/devkitARM

The essential libraries for 3DS homebrew development

  • libctru: the low-level C library for the 3DS system services (HID, filesystem, audio, GPU). It is the foundation of every 3DS homebrew program.
  • citro3d: an abstraction layer over the PICA200 GPU. It handles render targets, the framebuffer and graphics synchronisation.
  • citro2d: a 2D layer on top of citro3d. It provides simple primitives (rectangles, circles, ellipses, lines, text, sprites), which is exactly what you want for 2D games on the 3DS.

Extra tools for packaging

  • bannertool: generates the banner.bin and icon.bin files required by the CIA format
  • makerom: assembles the final .cia file from the ELF, the RSF, the banner and the icon
  • tex3ds: converts PNG images into .t3x textures that citro2d can hand to the PICA200 GPU
  • mkbcfnt: converts TTF fonts into the .bcfnt format used for text rendering on the 3DS (Unicode, CJK and Cyrillic supported)

3. 3DS homebrew project architecture: separating logic from rendering

The classic beginner mistake in homebrew development is writing everything directly against the console APIs. The result is code you cannot test or debug properly. I have been there, and the advice I would give anyone is to separate game logic from rendering right from the start.

That is exactly the approach behind 2048-3DS: the 2048 game logic (grid, moves, merges, score) is 100% portable, while rendering is specific to each platform.

File structure of the 2048-3DS project

treeview
2048-3ds/
├── source/
│   ├── logic.h          # Interface du jeu 2048 (portable, sans dépendance)
│   ├── logic.c          # Logique du jeu (grille 4x4, mouvements, score)
│   ├── achievements.h   # Système de succès (8 paliers de score)
│   ├── achievements.c   # Sauvegarde/chargement binaire des succès
│   ├── lang.h           # Localisation (13 langues, compile-time)
│   ├── main_sdl.c       # Rendu PC avec SDL2 (simulation double écran)
│   └── main_3ds.c       # Rendu Nintendo 3DS avec citro2d
├── romfs/               # Assets embarqués dans le binaire .3dsx/.cia
│   ├── font.bcfnt       # Police custom avec glyphes CJK/cyrillique
│   ├── sprites.t3x      # Sprite sheet compile (icônes de succès)
│   └── music/           # Musique et effets sonores (WAV PCM obligatoire)
├── assets/              # Ressources PC + assets pour bannière CIA
├── gfx/                 # Sources des sprites (PNG + config .t3s)
├── Makefile             # Build PC
├── Makefile.3ds         # Build 3DS (devkitARM + citro2d)
└── app.rsf              # Configuration CIA (permissions, titre, UniqueId)

Portable code: 2048 game logic with no dependencies

The logic.c file in 2048-3DS includes no Nintendo 3DS header at all. It relies only on standard C headers (<stdint.h>, <stdlib.h>, <string.h>, <time.h>). The main_3ds.c file calls the public functions of that logic:

c
// logic.h — portable interface of the 2048 game
#define GRID_SIZE 4
#define MAX_TILE_ANIMS 16

typedef enum { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT } Direction;

typedef struct {
    int from_r, from_c;   // position before the move
    int to_r, to_c;       // position after the move
    uint16_t value;       // value displayed during the animation
    int merged;           // 1 if merged
} TileAnim;

typedef struct {
    uint16_t cells[GRID_SIZE][GRID_SIZE];  // 4x4 grid
    uint32_t score;
    int won, over;
    TileAnim anims[MAX_TILE_ANIMS];  // animation data for the last move
    int anim_count;
    int spawn_r, spawn_c;     // position of the new tile
    uint16_t spawn_val;        // value (2 at 90%, 4 at 10%)
} Game;

void game_init(Game *g);                    // empty grid + 2 tiles
int  game_move(Game *g, Direction dir);     // returns 1 if the grid changed
int  game_is_over(Game *g);                 // no move possible
int  game_has_won(Game *g);                 // tile >= 2048 reached

The 3DS Makefile compiles logic.c + achievements.c + main_3ds.c and explicitly filters out main_sdl.c. The game logic stays identical whatever the rendering front-end.

4. Working with the Nintendo 3DS dual screen

What sets the Nintendo 3DS apart from every other handheld is its dual screen. Using it well is key to a good homebrew experience.

  • Top screen: 400×240 pixels, no touch input. Perfect for the main gameplay. In 2048-3DS this is where the 4×4 grid and the tile animations live.
  • Bottom screen: 320×240 pixels, touch-enabled. Perfect for the interface, menus and controls. In 2048-3DS it shows the score, the buttons, the settings and the achievements.

Setting up render targets with citro2d

c
// Initialise the 3DS graphics system
gfxInitDefault();
C3D_Init(C3D_DEFAULT_CMDBUF_SIZE);
C2D_Init(C2D_DEFAULT_MAX_OBJECTS);
C2D_Prepare();

// Create a render target for each screen
C3D_RenderTarget *top = C2D_CreateScreenTarget(GFX_TOP, GFX_LEFT);
C3D_RenderTarget *bot = C2D_CreateScreenTarget(GFX_BOTTOM, GFX_LEFT);

The dual-screen render loop

On every frame you draw to each screen separately. Here is the shape of the render loop used in 2048-3DS:

c
C3D_FrameBegin(C3D_FRAME_SYNCDRAW);

// Top screen — the 2048 game grid
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_top_screen(&game, anim_phase, progress);

// Bottom screen — UI (score, buttons, settings)
C2D_TargetClear(bot, col_bot_bg);
C2D_SceneBegin(bot);
render_bot_game(&game, best_score, 0, music_muted);

C3D_FrameEnd(0);

The critical 3DS rendering trap: C2D_TargetClear is mandatory. Skip C2D_TargetClear() before each scene and you will get random visual artefacts, leftover VRAM from the previous frame. The 3DS does not clear its VRAM for you. The bug is deeply confusing for homebrew beginners because the artefacts change on every frame.

Screen dimensions: the constants you need

c
#define TOP_W  400   // top screen width
#define TOP_H  240   // top screen height
#define BOT_W  320   // bottom screen width (touch)
#define BOT_H  240   // bottom screen height

One thing to keep in mind: on the physical console the bottom screen is horizontally centred under the top one (400 against 320 pixels wide). Touch coordinates map directly to bottom-screen pixels, with (0,0) in the top-left corner.

5. 2D rendering with citro2d on the Nintendo 3DS

citro2d is the go-to 2D rendering library for 3DS homebrew games. Built on top of citro3d and the PICA200 GPU, it batches primitives and manages GPU textures for you. Here is how to use it, with real examples taken from 2048-3DS.

Drawing primitives: rectangles, circles, ellipses and lines

c
// Filled rectangle (2048 game tiles)
C2D_DrawRectSolid(x, y, z, largeur, hauteur, couleur);

// Filled circle (rounded corners, icons)
C2D_DrawCircleSolid(centre_x, centre_y, z, rayon, couleur);

// Filled ellipse (music icon in 2048-3DS)
C2D_DrawEllipseSolid(x, y, z, largeur, hauteur, couleur);

// Line (audio mute bar)
C2D_DrawLine(x1, y1, couleur1, x2, y2, couleur2, epaisseur, z);

The z parameter controls depth (0.0f for the standard plane). Colours on the 3DS are stored as ABGR in memory (note the reversed order compared with the usual ARGB). citro2d gives you the C2D_Color32(r, g, b, a) macro to build them correctly:

c
// C99 pitfall: C2D_Color32() is not constexpr
// Use a macro for global colour constants
#define MAKE_COLOR(r,g,b,a) \
    ((u32)(r) | ((u32)(g)<<8) | ((u32)(b)<<16) | ((u32)(a)<<24))

// Colour palette of 2048 on the 3DS
#define col_grid_bg  MAKE_COLOR(0xBB, 0xAD, 0xA0, 0xFF)  // grid background
#define col_bot_bg   MAKE_COLOR(0xFA, 0xF8, 0xEF, 0xFF)  // bottom screen background
#define col_text_dk  MAKE_COLOR(0x77, 0x6E, 0x65, 0xFF)  // dark text
#define col_text_lt  MAKE_COLOR(0xF9, 0xF6, 0xF2, 0xFF)  // light text

Rounded rectangles: a citro2d drawing trick

citro2d has no “rounded rectangle” primitive. For the 2048 game tiles and the interface buttons, I built one by combining a central rectangle, two side rectangles and four corner circles:

c
static void fill_rounded_rect(float x, float y, float w, float h,
                                float r, u32 clr)
{
    if (r < 1.0f || w < 2*r || h < 2*r) {
        C2D_DrawRectSolid(x, y, 0.0f, w, h, clr);
        return;
    }
    // Central body + side edges
    C2D_DrawRectSolid(x + r, y, 0.0f, w - 2*r, h, clr);
    C2D_DrawRectSolid(x, y + r, 0.0f, r, h - 2*r, clr);
    C2D_DrawRectSolid(x + w - r, y + r, 0.0f, r, h - 2*r, clr);
    // 4 circles of radius r at the corners
    C2D_DrawCircleSolid(x + r, y + r, 0.0f, r, clr);
    C2D_DrawCircleSolid(x + w - r, y + r, 0.0f, r, clr);
    C2D_DrawCircleSolid(x + r, y + h - r, 0.0f, r, clr);
    C2D_DrawCircleSolid(x + w - r, y + h - r, 0.0f, r, clr);
}

That function is used everywhere in 2048-3DS: game tiles, score boxes, buttons, volume sliders, confirmation dialogs.

Drawing text on the Nintendo 3DS with the .bcfnt format

Text rendering on the 3DS goes through the .bcfnt format (Binary CTR Font). Every string has to be parsed into a text buffer before it can be drawn. In 2048-3DS this is how scores, button labels in 13 languages and achievement notifications get on screen:

c
// Initialise the text buffers
C2D_TextBuf s_dynamicBuf = C2D_TextBufNew(512);
C2D_Font s_font = C2D_FontLoad("romfs:/font.bcfnt");

// Helper function: draw centred text
static void draw_text_centered(const char *str, float cx, float cy,
                                float scale, u32 color)
{
    C2D_Text text;
    C2D_TextFontParse(&text, s_font, s_dynamicBuf, str);
    C2D_TextOptimize(&text);
    float w, h;
    C2D_TextGetDimensions(&text, scale, scale, &w, &h);
    C2D_DrawText(&text, C2D_WithColor,
                 cx - w / 2, cy - h * 0.62f,
                 0.0f, scale, scale, color);
}

// Important: clear the text buffer on every frame
C2D_TextBufClear(s_dynamicBuf);

The 0.62f factor is tuned so that text is visually centred vertically.

Sprites and GPU textures in the .t3x format

Images have to be converted to the .t3x format (a 3DS texture optimised for the PICA200 GPU) with the tex3ds tool. A .t3s file describes the sprite sheet. In 2048-3DS the 8 achievement icons (32×32 pixels each) are packed into a single sprite sheet:

code
# gfx/sprites.t3s — configuration du sprite sheet pour tex3ds
--atlas -f rgba8888 -z auto
sprite_0.png    # Debutant (vert)
sprite_1.png    # Apprenti (bleu)
sprite_2.png    # Competent (violet)
sprite_3.png    # Expert (orange)
sprite_4.png    # Maitre (rouge)
sprite_5.png    # Grand Maitre (rose)
sprite_6.png    # Legende (or)
sprite_7.png    # Titan (or brillant)
c
// Load the sprite sheet from RomFS
C2D_SpriteSheet sheet = C2D_SpriteSheetLoad("romfs:/sprites.t3x");

// Drawing an achievement icon
C2D_Image img = C2D_SpriteSheetGetImage(sheet, index);
C2D_DrawImageAt(img, x, y, 0.0f, NULL, scale_x, scale_y);

// Greyscale rendering (locked achievement)
C2D_ImageTint tint;
C2D_PlainImageTint(&tint, C2D_Color32(128, 128, 128, 255), 1.0f);
C2D_DrawImageAt(img, x, y, 0.0f, &tint, scale_x, scale_y);

Smooth animations on the Nintendo 3DS

For smooth animations in a 3DS homebrew game, use osGetTime() (time in milliseconds) together with eased interpolation. Here is the animation system that drives tile sliding and spawning in 2048-3DS:

c
// Animation timings for 2048
#define ANIM_SLIDE_MS  120    // tile slide duration
#define ANIM_POP_MS    100    // duration of the "pop" (merge/spawn)

typedef enum { ANIM_NONE, ANIM_SLIDING, ANIM_POPPING } AnimPhase;

// Quadratic easing: natural deceleration
static float ease_out_quad(float t) {
    return t * (2.0f - t);
}

// In the main loop: interpolating positions
u64 now = osGetTime();
u64 elapsed = now - anim_start;
float progress = (float)elapsed / ANIM_SLIDE_MS;
float t = ease_out_quad(progress);

// Linear tile slide
float cur_x = from_px + (to_px - from_px) * t;
float cur_y = from_py + (to_py - from_py) * t;

The animation runs in two phases: the slide first (120 ms), then the “pop”, merged tiles grow to 125% before settling back to 100%, and the new tile scales up from 0 to 100%. That system makes 2048 on the 3DS feel as satisfying as the original.

6. Input handling on the Nintendo 3DS: D-pad, Circle Pad and touch screen

The Nintendo 3DS offers several input devices to homebrew games, all exposed through the libctru (HID) library. 2048-3DS uses all three: D-pad and Circle Pad to move tiles, touch screen for buttons and sliders.

Reading the physical D-pad and A/B/X/Y buttons

c
hidScanInput();            // Scan the input state (once per frame)
u32 kDown = hidKeysDown(); // Buttons newly pressed this frame

// D-pad: move the tiles in 2048
if (kDown & KEY_DUP)    { dir = DIR_UP;    do_move = 1; }
if (kDown & KEY_DDOWN)  { dir = DIR_DOWN;  do_move = 1; }
if (kDown & KEY_DLEFT)  { dir = DIR_LEFT;  do_move = 1; }
if (kDown & KEY_DRIGHT) { dir = DIR_RIGHT; do_move = 1; }

// System buttons
if (kDown & KEY_B)      { /* back navigation */ }
if (kDown & KEY_START)  { break; /* quit the application */ }
if (kDown & KEY_SELECT) { game_init(&game); /* new game */ }

Circle Pad: the 3DS analogue stick and its deadzone

c
circlePosition cpad;
hidCircleRead(&cpad);

// Deadzone of 80 to avoid false positives
// Circle Pad range: roughly -155 to +155
if (cpad.dy >  80) { dir = DIR_UP;    do_move = 1; }
if (cpad.dy < -80) { dir = DIR_DOWN;  do_move = 1; }
if (cpad.dx < -80) { dir = DIR_LEFT;  do_move = 1; }
if (cpad.dx >  80) { dir = DIR_RIGHT; do_move = 1; }

A deadzone is essential on the 3DS Circle Pad. Without a threshold the stick reports micro-movements non-stop and triggers moves the player never asked for. A value of 80 (on a range of roughly -155 to +155) works well in practice for a game like 2048.

Touch screen: handling taps on interface buttons

The 3DS touch screen is resistive (pressure-based, not capacitive). Coordinates come in directly as bottom-screen pixels (320×240). In 2048-3DS the touch screen drives the “New game”, “Achievements” and “Settings” buttons, the volume sliders and the language picker:

c
if (kDown & KEY_TOUCH) {
    touchPosition touch;
    hidTouchRead(&touch);
    int mx = touch.px;  // 0..319
    int my = touch.py;  // 0..239

// Tap detection on the "New game" button
    float btn_x = (BOT_W - BTN_W) / 2;  // horizontally centred
    float btn_y = 112;
    if (mx >= btn_x && mx <= btn_x + BTN_W &&
        my >= btn_y && my <= btn_y + BTN_H) {
        game_init(&game);
        anim_phase = ANIM_NONE;
    }
}

Use hidKeysDown() with KEY_TOUCH for single taps (buttons), and hidKeysHeld() for continuous interactions (volume sliders, dragging).

The main loop of a 3DS homebrew game

c
while (aptMainLoop()) {
    u64 now = osGetTime();
    audio_music_tick(music_volume, music_muted);

hidScanInput();
    u32 kDown = hidKeysDown();

if (kDown & KEY_START) break;  // Clean exit

// Input handling (D-pad, Circle Pad, touch)
    // Update the 2048 game logic
    // Render both screens
    // ...
}

aptMainLoop() handles the homebrew application lifecycle: sleep mode, being closed by the system, returning to the HOME menu.

7. 3DS homebrew audio: NDSP and PCM WAV files

Take it from me: audio on the Nintendo 3DS is where I lost the most time in homebrew development. It all looks simple on paper, but the traps are everywhere. The 3DS audio backend is NDSP (Nintendo DSP): it needs the DSP firmware file (dspfirm.bin on the SD card) and supports multi-channel mixing with linear interpolation.

In 2048-3DS I implemented a four-track soundtrack plus sound effects for tile moves and achievements.

Audio format for the 3DS: raw PCM WAV only

The biggest trap in 3DS audio development: the console only supports raw PCM WAV (no MP3, no OGG, no ADPCM-compressed WAV). Files must be 8-bit or 16-bit PCM, mono or stereo. Anything else is either silently ignored or crashes the application.

Memory allocation for 3DS audio: linearAlloc, not malloc

Audio buffers on the 3DS must be allocated with linearAlloc(), never malloc(). Linear memory is directly accessible to the DSP processor, the standard heap is not.

c
// Struct holding a WAV audio file on the 3DS
typedef struct {
    u8  *data;            // PCM data (allocated with linearAlloc)
    u32  size;            // Size in bytes
    u32  sample_rate;     // Sample rate
    u16  channels;        // 1 (mono) or 2 (stereo)
    u16  bits_per_sample; // 8 or 16 bits
    ndspWaveBuf wave_buf; // NDSP buffer
    int  loaded;          // Successful load flag
} WavSound;

Loading WAV files in a 3DS homebrew

c
static int wav_load(WavSound *snd, const char *path)
{
    FILE *f = fopen(path, "rb");
    if (!f) return -1;

// Read and validate the RIFF/WAVE header
    char riff[4]; u32 file_size; char wave[4];
    fread(riff, 1, 4, f);
    fread(&file_size, 4, 1, f);
    fread(wave, 1, 4, f);
    if (memcmp(riff, "RIFF", 4) != 0 ||
        memcmp(wave, "WAVE", 4) != 0) {
        fclose(f); return -1;
    }

// Walk the WAV chunks (fmt + data)
    while (!got_data) {
        char chunk_id[4]; u32 chunk_size;
        fread(chunk_id, 1, 4, f);
        fread(&chunk_size, 4, 1, f);

if (memcmp(chunk_id, "fmt ", 4) == 0) {
            // Extract channels, sample_rate, bits_per_sample
            // ...
        } else if (memcmp(chunk_id, "data", 4) == 0) {
            // IMPORTANT: linearAlloc, not malloc!
            snd->data = (u8 *)linearAlloc(chunk_size);
            fread(snd->data, 1, chunk_size, f);
        } else {
            fseek(f, chunk_size, SEEK_CUR);
        }
    }

// Prepare the NDSP buffer
    snd->wave_buf.data_vaddr = snd->data;
    snd->wave_buf.nsamples = snd->size /
        (snd->channels * snd->bits_per_sample / 8);
    snd->wave_buf.looping = false;

// MANDATORY: flush the CPU cache to the DSP
    DSP_FlushDataCache(snd->data, snd->size);
    return 0;
}

DSP_FlushDataCache is mandatory after every write into a 3DS audio buffer. Without that call the DSP reads corrupted data, because the CPU cache is not coherent with DSP memory. It is the number one cause of silent audio bugs on the Nintendo 3DS.

Initialising NDSP audio

Here is the audio initialisation used in 2048-3DS:

c
static void audio_init(void)
{
    if (ndspInit() != 0) return;

ndspSetOutputMode(NDSP_OUTPUT_STEREO);

// Channel 0: background music
    // Channel 1: SFX (tile move)
    // Channel 2: SFX (achievement unlocked)
    for (int ch = 0; ch < 3; ch++) {
        ndspChnReset(ch);
        ndspChnSetInterp(ch, NDSP_INTERP_LINEAR);
        ndspChnSetFormat(ch, NDSP_FORMAT_STEREO_PCM16);
    }
}

Playing a sound and handling volume on the 3DS

c
// NDSP: add the audio buffer to the channel
snd->wave_buf.status = NDSP_WBUF_FREE;  // reset the status
DSP_FlushDataCache(snd->data, snd->size);
ndspChnWaveBufAdd(channel, &snd->wave_buf);

// NDSP volume: array of 12 floats (stereo mix per channel)
float vol = (float)music_volume / 128.0f;  // 0..128 -> 0.0..1.0
float mix[12] = {0};
mix[0] = vol;  // left channel
mix[1] = vol;  // right channel
ndspChnSetMix(0, mix);

Chaining music tracks automatically

2048-3DS chains four music tracks automatically (intro, music1, music2, music3). On every frame I check whether the current track has finished:

c
static void audio_music_tick(int music_volume, int music_muted)
{
    if (!s_audio_init || music_muted) return;

if (!ndspChnIsPlaying(0)) {
        // Move on to the next track (cyclic loop)
        s_music_current = (s_music_current + 1) % MUSIC_TRACK_COUNT;
        audio_play_current_track(music_volume, music_muted);
    }
}

Cleaning up audio when the program exits

c
// Release the audio resources cleanly
ndspChnReset(0);
ndspChnReset(1);
ndspChnReset(2);
ndspExit();

// Free the linear memory (not free, but linearFree!)
for (int i = 0; i < MUSIC_TRACK_COUNT; i++)
    linearFree(s_music[i].data);
linearFree(s_sfx_push.data);
linearFree(s_sfx_ach.data);

8. The 3DS file system: RomFS and the SD card

The Nintendo 3DS exposes two file systems to homebrew applications: RomFS for read-only assets and SDMC for reading and writing on the SD card.

RomFS: bundling assets inside the homebrew binary

RomFS (Read-Only Memory FileSystem) lets you embed files directly inside the .3dsx or .cia binary. It is ideal for assets that never change: fonts, textures, music, sprites. In 2048-3DS the romfs/ folder holds the .bcfnt font with CJK support, the achievement sprite sheet and six WAV audio files:

c
// Mandatory init before any RomFS access
romfsInit();

// Access the assets with the romfs:/ prefix
C2D_Font font = C2D_FontLoad("romfs:/font.bcfnt");
C2D_SpriteSheet sheet = C2D_SpriteSheetLoad("romfs:/sprites.t3x");
wav_load(&s_music[0], "romfs:/music/intro.wav");

The contents of the romfs/ folder are embedded automatically by the 3DS Makefile:

bash
# In Makefile.3ds
ROMFS := romfs

Asset formats specific to the Nintendo 3DS

  • .bcfnt: a bitmap font compiled with mkbcfnt. It supports full Unicode (CJK, Cyrillic, accented characters). In 2048-3DS a single font covers all 13 languages.
  • .t3x: a GPU texture compiled with tex3ds. The format is optimised for the PICA200 and loads straight into VRAM with no conversion.
  • .wav: raw PCM audio. No runtime conversion, but the file must be uncompressed PCM (see the audio section).

SDMC: saving data to the 3DS SD card

To save data (scores, settings, progress), 3DS homebrew writes to the SD card using the sdmc:/ prefix:

c
// Save paths for 2048-3DS
#define SAVE_DIR           "sdmc:/3ds/2048/"
#define ACH_SAVE_PATH      "sdmc:/3ds/2048/achievements.dat"
#define SETTINGS_SAVE_PATH "sdmc:/3ds/2048/settings.dat"

// Create the save directory at startup
#include <sys/stat.h>
mkdir("sdmc:/3ds", 0777);
mkdir(SAVE_DIR, 0777);

Writing uses the standard C functions (fopen, fwrite, fclose). No 3DS-specific API is needed for file operations on SDMC.

9. Saving data to the SD card in a 3DS homebrew

For a 3DS homebrew game, a binary save file is the most direct and the cheapest option. Here is how 2048-3DS persists achievements and settings.

Binary save file for the achievement system

2048-3DS has 8 achievements (score tiers: 500, 1000, 2500, 5000, 10000, 20000, 50000, 100000 points). The save file is 8 bytes long, one byte per achievement:

c
// Save: 8 bytes (0x00 = locked, 0x01 = unlocked)
int achievements_save(const Achievements *a, const char *path)
{
    FILE *f = fopen(path, "wb");
    if (!f) return -1;
    for (int i = 0; i < ACH_COUNT; i++) {
        uint8_t flag = (uint8_t)a->list[i].unlocked;
        fwrite(&flag, 1, 1, f);
    }
    fclose(f);
    return 0;
}

// Load with default values if the file does not exist
int achievements_load(Achievements *a, const char *path)
{
    achievements_init(a);  // all locked by default
    FILE *f = fopen(path, "rb");
    if (!f) return -1;     // first run: no file yet
    for (int i = 0; i < ACH_COUNT; i++) {
        uint8_t flag = 0;
        if (fread(&flag, 1, 1, f) != 1) break;
        a->list[i].unlocked = flag ? 1 : 0;
    }
    fclose(f);
    return 0;
}

Saving user settings

The 2048-3DS settings (language, music volume, effects volume, mute state) are stored in 4 bytes:

c
// Format: [0] = language, [1] = music volume, [2] = SFX volume, [3] = mute
static void settings_save(int music_volume, int sfx_volume, int music_muted)
{
    FILE *f = fopen(SETTINGS_SAVE_PATH, "wb");
    if (!f) return;
    u8 data[4];
    data[0] = (u8)lang_current;           // enum Language (0..12)
    data[1] = (u8)music_volume;           // 0..128
    data[2] = (u8)sfx_volume;             // 0..128
    data[3] = (u8)(music_muted ? 1 : 0);
    fwrite(data, 1, 4, f);
    fclose(f);
}

Why this works well for a homebrew game: fixed size, no parsing, no dependency on a JSON or XML library, instant reads. Settings are saved automatically when the player leaves the settings menu or quits the application.

10. The 3DS Makefile: ARM cross-compilation with devkitARM

The build system for 3DS homebrew is built on the rules shipped with devkitARM. Understanding the 3DS Makefile is essential when you have to debug a build problem.

Structure of a 3DS homebrew Makefile

bash
# Check that devkitARM is set
ifeq ($(strip $(DEVKITARM)),)
$(error "Please set DEVKITARM in your environment")
endif

include $(DEVKITARM)/3ds_rules

# Homebrew project configuration
TARGET   := 2048
BUILD    := build_3ds
SOURCES  := source
ROMFS    := romfs

# Metadata shown in the Homebrew Launcher and the HOME menu
APP_TITLE       := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR      := Gekkode

ARM compilation flags for the Nintendo 3DS

makefile
ARCH := -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft

CFLAGS := -g -Wall -Wextra -O2 -mword-relocations \
          -ffunction-sections $(ARCH) -std=c99
CFLAGS += $(INCLUDE) -D__3DS__

What each flag of the ARM cross-compilation for the 3DS does:

  • -march=armv6k: the architecture of the Nintendo 3DS ARM11 processor
  • -mtune=mpcore: tunes the generated code for the MPCore
  • -mfloat-abi=hard: uses the VFP (hardware floating point), which matters a lot for graphics performance
  • -mword-relocations: emits the relocations the 3DSX format needs
  • -ffunction-sections: lets the linker drop dead code and shrink the binary
  • -D__3DS__: defines the macro used by the #ifdef blocks that separate PC and 3DS code

Linking against citro2d and libctru

makefile
LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
LIBS    := -lcitro2d -lcitro3d -lctru -lm

Library order matters to the GNU linker: -lcitro2d depends on -lcitro3d, which depends on -lctru. Always go from the highest level of abstraction down to the lowest.

Filtering sources: keeping the PC renderer out of the 3DS build

bash
# Exclude main_sdl.c from the 3DS build (only main_3ds.c is compiled)
CFILES := $(filter-out main_sdl.c, \
    $(foreach dir,$(SOURCES),$(notdir $(wildcard $(dir)/*.c))))

That filter guarantees only main_3ds.c is compiled into the 3DS build.

Build commands

bash
# Build the homebrew as a .3dsx
make -f Makefile.3ds

# Build and generate an installable .cia
make -f Makefile.3ds cia

# Full clean
make -f Makefile.3ds clean

11. Building a .3dsx file for the Homebrew Launcher

The .3dsx format is the standard Nintendo 3DS homebrew format, launched from the Homebrew Launcher. It is the easiest one to distribute: a single file to copy onto the SD card.

The 3DS Makefile produces two files automatically:

  • 2048.3dsx: the homebrew executable, with RomFS embedded
  • 2048.smdh: the SMDH metadata (title, author, description, icon)

SMDH: the metadata of your 3DS homebrew

The SMDH file (Simple Metadata Header) holds the information shown in the Homebrew Launcher:

bash
# Defined in Makefile.3ds
APP_TITLE       := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR      := Gekkode

The icon is a 48×48 pixel PNG, picked up automatically if icon.png or 2048.png sits at the root of the project.

Embedding RomFS in the .3dsx

RomFS is baked straight into the .3dsx file. The end user only ever has one file to deal with:

c
_3DSXFLAGS += --romfs=$(CURDIR)/romfs

12. Building an installable .cia file for the 3DS HOME menu

The .cia format (CTR Importable Archive) is the installable Nintendo 3DS format. Once installed through FBI or another title manager, the game appears in the console HOME menu with a custom icon, an animated banner and a launch sound.

It is the format used to ship 2048 for Nintendo 3DS as a .cia.

Be warned: this is by far the trickiest stage of 3DS homebrew development. I spent an absurd amount of time working out why a .cia could run flawlessly in the Citra emulator and crash instantly on a real 3DS. The cause is almost always a badly configured RSF file. What follows is the result of many hours of testing on real hardware, I go through every setting so you do not have to suffer the same way.

Assets required to build a .cia

  • Icon: a PNG of exactly 48×48 pixels (shown in the HOME menu)
  • Banner image: a PNG of exactly 256×128 pixels (shown at the top of the screen when the game is selected)
  • Banner sound: a 16-bit PCM WAV, 44100 Hz, stereo, around 3 seconds long (played when the game is selected in the HOME menu). The format is strict, use ffmpeg to convert:
bash
# Convert any audio into a bannertool-compatible WAV
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 44100 -ac 2 -t 3 banner.wav

Building a CIA file for the 3DS, step by step

Building a .cia takes four steps. The approach I recommend is an RSF template with $(VARIABLE) placeholders substituted through the -DVARIABLE="valeur" flags of makerom. That keeps the configuration (in the Makefile) separate from the permissions template (in the RSF), which makes the build cleaner and reusable:

bash
# 1. Build the ELF binary
make -f Makefile.3ds

# 2. Generate the banner (image + launch audio)
bannertool makebanner \
    -i assets/banner.png \
    -a assets/music/banner.wav \
    -o build_3ds/banner.bnr

# 3. Generate the SMDH icon for the HOME menu
bannertool makesmdh \
    -s "2048" \
    -l "2048 - puzzle game for 3DS" \
    -p "Gekkode" \
    -i icon.png \
    -o build_3ds/icon.icn

# 4. Assemble the final .cia with makerom
makerom -f cia -o 2048.cia \
    -elf 2048.elf \
    -rsf app.rsf \
    -target t \
    -exefslogo \
    -icon build_3ds/icon.icn \
    -banner build_3ds/banner.bnr \
    -major 1 -minor 0 -micro 0 \
    -DAPP_TITLE="2048" \
    -DAPP_PRODUCT_CODE="CTR-H-2048" \
    -DAPP_UNIQUE_ID="0xF2048" \
    -DAPP_ENCRYPTED=false \
    -DAPP_SYSTEM_MODE="64MB" \
    -DAPP_SYSTEM_MODE_EXT="Legacy" \
    -DAPP_CATEGORY="Application" \
    -DAPP_USE_ON_SD="true" \
    -DAPP_MEMORY_TYPE="Application" \
    -DAPP_CPU_SPEED="268MHz" \
    -DAPP_ENABLE_L2_CACHE="false" \
    -DAPP_VERSION_MAJOR="1" \
    -DAPP_ROMFS="romfs"

Each -DXXX="valeur" flag replaces the matching $(XXX) variable in the RSF file. Thanks to that substitution you can keep a single reusable RSF template for all your projects, only the -D flags change from one project to the next.

The makerom flags that matter:

  • -target t: the “test” target (for homebrew that Nintendo has not signed)
  • -exefslogo: includes the logo in the ExeFS (needed for the boot splash screen)
  • -major / -minor / -micro: the title version (shown in the system settings)

The RSF file: setting the permissions of your homebrew CIA

The RSF file (ROM Specification File) is the most critical part of the CIA build. It defines the metadata, the system permissions, the allowed services and the available system calls. An incomplete RSF means an instant crash on real hardware, even when everything works in Citra.

Here is the complete RSF template used for 2048-3DS, refined over many rounds of testing on real hardware. Every section is explained below:

BasicInfo: the identity of the title

yaml
BasicInfo:
  Title                   : $(APP_TITLE)
  ProductCode             : $(APP_PRODUCT_CODE)
  Logo                    : Nintendo
  • Title: the name shown in the system settings. It uses the $(APP_TITLE) variable, substituted by -DAPP_TITLE="2048".
  • ProductCode: an identifier in the CTR-H-XXXX form. The H stands for homebrew. Pick a unique code (for example CTR-H-2048).
  • Logo: the animated splash screen shown at boot.
    • Nintendo: the official animated 3DS logo (recommended, this is the standard behaviour)
    • Homebrew: the homebrew community logo
    • Licensed / Distributed: variants of the Nintendo logo
    • None: avoid this one, it can crash on some firmware versions

RomFs: embedded resources

c
RomFs:
  RootPath: $(APP_ROMFS)

The path to the romfs/ folder holding your embedded assets (sprites, fonts, audio). Passed in through -DAPP_ROMFS="romfs". If your application has no RomFS, you can leave this section out.

TitleInfo: unique identification

yaml
TitleInfo:
  Category                : $(APP_CATEGORY)
  UniqueId                : $(APP_UNIQUE_ID)
  • Category: always Application for a homebrew game or utility.
  • UniqueId: a unique hexadecimal identifier in the 0xF0000 to 0xFFFFF range (the range reserved for homebrew). Every homebrew installed on the console needs a different UniqueId to avoid clashes. For 2048 I use 0xF2048.

Option: packaging options

c
Option:
  UseOnSD                 : $(APP_USE_ON_SD)
  FreeProductCode         : true
  MediaFootPadding        : false
  EnableCrypt             : $(APP_ENCRYPTED)
  EnableCompress          : true
  • UseOnSD: true (required for homebrew installed on the SD card).
  • FreeProductCode: true so that makerom accepts a free-form ProductCode (no Nintendo format check).
  • EnableCrypt: false (homebrew has no Nintendo encryption keys).
  • EnableCompress: true to compress the ExeFS .code section (it shrinks the .cia).

AccessControlInfo: the critical section

This is where 90% of CIA crashes are decided. This section defines everything your application is allowed to do on the console. Miss a single system call or service and the CIA crashes instantly on real hardware, while Citra happily ignores those restrictions.

yaml
AccessControlInfo:
  CoreVersion                   : 2
  DescVersion                   : 2
  ReleaseKernelMajor            : "02"
  ReleaseKernelMinor            : "33"
  UseExtSaveData                : false
  MemoryType                    : $(APP_MEMORY_TYPE)
  SystemMode                    : $(APP_SYSTEM_MODE)
  SystemModeExt                 : $(APP_SYSTEM_MODE_EXT)
  CpuSpeed                      : $(APP_CPU_SPEED)
  EnableL2Cache                 : $(APP_ENABLE_L2_CACHE)
  IdealProcessor                : 0
  AffinityMask                  : 1
  Priority                      : 16
  MaxCpu                        : 0x9E
  HandleTableSize               : 0x200
  DisableDebug                  : false
  EnableForceDebug              : false
  CanWriteSharedPage            : true
  CanUsePrivilegedPriority      : false
  CanUseNonAlphabetAndNumber    : true
  PermitMainFunctionArgument    : true
  CanShareDeviceMemory          : true
  RunnableOnSleep               : false
  SpecialMemoryArrange          : true
  CanAccessCore2                : true

The settings to adapt to your own project:

  • MemoryType: Application for a standard game or utility. System for a system module.
  • SystemMode: how much RAM is allocated. 64MB is the standard. Only use 96MB for New 3DS exclusive applications that genuinely need more memory.
  • SystemModeExt: Legacy for Old 3DS + New 3DS compatibility.
  • CpuSpeed: 268MHz enables the clock boost on New 3DS (it falls back to 268 MHz on Old 3DS, handled automatically).
  • EnableL2Cache: false by default. Set it to true for CPU-heavy applications on New 3DS (it can cause instability on Old 3DS).

Then come the four permission sub-sections. My advice: include the full set. A basic homebrew will not use all these SVCs and services, but declaring too many costs nothing in performance or security on a CFW console, whereas forgetting a single one means an instant crash:

FileSystemAccess: file system access

yaml
  FileSystemAccess:
   - CategorySystemApplication
   - CategoryHardwareCheck
   - CategoryFileSystemTool
   - Debug
   - TwlCardBackup
   - TwlNandData
   - Boss
   - DirectSdmc
   - Core
   - CtrNandRo
   - CtrNandRw
   - CtrNandRoWrite
   - CategorySystemSettings
   - CardBoard
   - ExportImportIvs
   - DirectSdmcWrite
   - SwitchCleanup
   - SaveDataMove
   - Shop
   - Shell
   - CategoryHomeMenu
   - SeedDB

The ones that matter most for a basic homebrew: DirectSdmc + DirectSdmcWrite (SD read/write) and Core. Even so, including the full set is safer.

IoAccessControl: low-level I/O access

yaml
  IoAccessControl:
   - FsMountNand
   - FsMountNandRoWrite
   - FsMountTwln
   - FsMountWnand
   - FsMountCardSpi
   - UseSdif3
   - CreateSeed
   - UseCardSpi

SystemCallAccess: ARM11 system calls (SVCs)

This list defines which system calls (supervisor calls) your application may use. It is the most critical section of all: if an SVC is missing and libctru calls it, the console crashes immediately. Include every SVC from 1 to 125:

yaml
  SystemCallAccess:
    ControlMemory                 : 1
    QueryMemory                   : 2
    ExitProcess                   : 3
    GetProcessAffinityMask        : 4
    SetProcessAffinityMask        : 5
    GetProcessIdealProcessor      : 6
    SetProcessIdealProcessor      : 7
    CreateThread                  : 8
    ExitThread                    : 9
    SleepThread                   : 10
    GetThreadPriority             : 11
    SetThreadPriority             : 12
    GetThreadAffinityMask         : 13
    SetThreadAffinityMask         : 14
    GetThreadIdealProcessor       : 15
    SetThreadIdealProcessor       : 16
    GetCurrentProcessorNumber     : 17
    Run                           : 18
    CreateMutex                   : 19
    ReleaseMutex                  : 20
    CreateSemaphore               : 21
    ReleaseSemaphore              : 22
    CreateEvent                   : 23
    SignalEvent                   : 24
    ClearEvent                    : 25
    CreateTimer                   : 26
    SetTimer                      : 27
    CancelTimer                   : 28
    ClearTimer                    : 29
    CreateMemoryBlock             : 30
    MapMemoryBlock                : 31
    UnmapMemoryBlock              : 32
    CreateAddressArbiter          : 33
    ArbitrateAddress              : 34
    CloseHandle                   : 35
    WaitSynchronization1          : 36
    WaitSynchronizationN          : 37
    SignalAndWait                 : 38
    DuplicateHandle               : 39
    GetSystemTick                 : 40
    GetHandleInfo                 : 41
    GetSystemInfo                 : 42
    GetProcessInfo                : 43
    GetThreadInfo                 : 44
    ConnectToPort                 : 45
    SendSyncRequest1              : 46
    SendSyncRequest2              : 47
    SendSyncRequest3              : 48
    SendSyncRequest4              : 49
    SendSyncRequest               : 50
    OpenProcess                   : 51
    OpenThread                    : 52
    GetProcessId                  : 53
    GetProcessIdOfThread          : 54
    GetThreadId                   : 55
    GetResourceLimit              : 56
    GetResourceLimitLimitValues   : 57
    GetResourceLimitCurrentValues : 58
    GetThreadContext              : 59
    Break                         : 60
    OutputDebugString             : 61
    ControlPerformanceCounter     : 62
    CreatePort                    : 71
    CreateSessionToPort           : 72
    CreateSession                 : 73
    AcceptSession                 : 74
    ReplyAndReceive1              : 75
    ReplyAndReceive2              : 76
    ReplyAndReceive3              : 77
    ReplyAndReceive4              : 78
    ReplyAndReceive               : 79
    BindInterrupt                 : 80
    UnbindInterrupt               : 81
    InvalidateProcessDataCache    : 82
    StoreProcessDataCache         : 83
    FlushProcessDataCache         : 84
    StartInterProcessDma          : 85
    StopDma                       : 86
    GetDmaState                   : 87
    RestartDma                    : 88
    DebugActiveProcess            : 96
    BreakDebugProcess             : 97
    TerminateDebugProcess         : 98
    GetProcessDebugEvent          : 99
    ContinueDebugEvent            : 100
    GetProcessList                : 101
    GetThreadList                 : 102
    GetDebugThreadContext         : 103
    SetDebugThreadContext         : 104
    QueryDebugProcessMemory       : 105
    ReadProcessMemory             : 106
    WriteProcessMemory            : 107
    SetHardwareBreakPoint         : 108
    GetDebugThreadParam           : 109
    ControlProcessMemory          : 112
    MapProcessMemory              : 113
    UnmapProcessMemory            : 114
    CreateCodeSet                 : 115
    CreateProcess                 : 117
    TerminateProcess              : 118
    SetProcessResourceLimits      : 119
    CreateResourceLimit           : 120
    SetResourceLimitValues        : 121
    AddCodeSegment                : 122
    Backdoor                      : 123
    KernelSetState                : 124
    QueryProcessMemory            : 125

Worth noting: the numbers are not contiguous, there are gaps (63-70, 89-95, 110-111, 116). That is normal: those SVCs are reserved or not implemented by the 3DS kernel.

ServiceAccessControl: system services

Services are the high-level APIs of the 3DS. Every libctru library uses one or more of them. If a service is not declared here, the call to srvGetServiceHandle() fails and the matching library crashes during initialisation:

yaml
  ServiceAccessControl:
   - APT:U          # Application lifecycle (aptMainLoop)
   - ac:u           # Network configuration
   - am:net         # Application Manager (title installation)
   - boss:U         # SpotPass
   - cam:u          # Camera
   - cecd:u         # StreetPass
   - cfg:nor        # NOR configuration
   - cfg:u          # System configuration (language, region)
   - csnd:SND       # CSND audio
   - dsp::DSP       # NDSP audio (main backend)
   - frd:u          # Friend list
   - fs:USER        # File system (SDMC, RomFS)
   - gsp::Gpu       # PICA200 GPU (graphics)
   - gsp::Lcd       # LCD screen control
   - hid:USER       # Input (buttons, pad, touch)
   - http:C         # HTTP client
   - ir:rst         # Infrared (New 3DS C-stick)
   - ir:u           # Generic infrared
   - ir:USER        # User infrared
   - mic:u          # Microphone
   - mcu::HWC       # Hardware microcontroller
   - ndm:u          # Network Daemon Manager
   - news:s         # Notifications
   - nwm::EXT       # Extended Network Manager
   - nwm::UDS       # UDS Network Manager (local wireless)
   - ptm:sysm       # System Power/Timer Manager
   - ptm:u          # User Power/Timer Manager
   - pxi:dev        # PXI device access
   - soc:U          # Sockets (TCP/UDP networking)
   - ssl:C          # SSL/TLS
   - y2r:u          # YUV to RGB conversion

For a basic homebrew game, the indispensable services are APT:U, fs:USER, gsp::Gpu and hid:USER. Add dsp::DSP if you use audio, and cfg:u if you read the system configuration. As with the SVCs, though, including the full set is safer, there is no penalty for declaring a service you never use.

SystemControlInfo: stack, save data and dependencies

yaml
SystemControlInfo:
  SaveDataSize: 0KB
  RemasterVersion: $(APP_VERSION_MAJOR)
  StackSize: 0x40000
  Dependency:
    ac: 0x0004013000002402
    am: 0x0004013000001502
    boss: 0x0004013000003402
    camera: 0x0004013000001602
    cecd: 0x0004013000002602
    cfg: 0x0004013000001702
    codec: 0x0004013000001802
    csnd: 0x0004013000002702
    dlp: 0x0004013000002802
    dsp: 0x0004013000001a02
    friends: 0x0004013000003202
    gpio: 0x0004013000001b02
    gsp: 0x0004013000001c02
    hid: 0x0004013000001d02
    http: 0x0004013000002902
    i2c: 0x0004013000001e02
    ir: 0x0004013000003302
    mcu: 0x0004013000001f02
    mic: 0x0004013000002002
    ndm: 0x0004013000002b02
    news: 0x0004013000003502
    nim: 0x0004013000002c02
    nwm: 0x0004013000002d02
    pdn: 0x0004013000002102
    ps: 0x0004013000003102
    ptm: 0x0004013000002202
    ro: 0x0004013000003702
    socket: 0x0004013000002e02
    spi: 0x0004013000002302
    ssl: 0x0004013000002f02
  • SaveDataSize: 0KB if you save straight to the SD card, which is what most homebrew does. Set a size only if you use the system save API.
  • StackSize: 0x40000 (256 KB) is a solid default. Raise it if your app does heavy recursion or allocates large arrays on the stack.
  • Dependency: the title IDs of the system modules your application depends on. Careful: those title IDs must NOT carry an L suffix. Writing 0x0004013000002402L instead of 0x0004013000002402 can cause silent failures. Copy the list above exactly as it is.

IORegisterMapping and MemoryMapping

yaml
  IORegisterMapping:
   - 1ff00000-1ff7ffff
  MemoryMapping:
   - 1f000000-1f5fffff:r

These sections define the memory ranges and I/O registers your application can reach. Copy the values above unchanged, they cover the needs of every standard homebrew.

Adapting the RSF to your own project

The RSF template above is generic and reusable. To adapt it to a new project, all you change are the -D flags in the makerom command:

-D flag2048 valueWhat to change
APP_TITLE2048The name of your app
APP_PRODUCT_CODECTR-H-2048A unique CTR-H-XXXX
APP_UNIQUE_ID0xF2048A unique hex value in 0xF0000-0xFFFFF
APP_ROMFSromfsThe path to your romfs folder
APP_SYSTEM_MODE64MB96MB if New 3DS exclusive
APP_VERSION_MAJOR1Your major version number

The remaining values (APP_ENCRYPTED=false, APP_CATEGORY=Application, APP_USE_ON_SD=true and so on) are the same for every homebrew.

Automating the CIA build in the Makefile

bash
# Variables at the top of Makefile.3ds
APP_TITLE        := 2048
APP_PRODUCT_CODE := CTR-H-2048
APP_UNIQUE_ID    := 0xF2048
BANNER_IMAGE     := $(TOPDIR)/assets/banner.png
BANNER_AUDIO     := $(TOPDIR)/assets/music/banner.wav
RSF_FILE         := $(TOPDIR)/app.rsf

# CIA target — a single "make -f Makefile.3ds cia"
cia: all
    @bannertool makebanner -i $(BANNER_IMAGE) -a $(BANNER_AUDIO) \
        -o $(BUILD)/banner.bnr
    @bannertool makesmdh -s "$(APP_TITLE)" -l "$(APP_TITLE) - $(APP_DESCRIPTION)" \
        -p "$(APP_AUTHOR)" -i $(APP_ICON) -o $(BUILD)/icon.icn
    @makerom -f cia -o $(TARGET).cia \
        -elf $(TARGET).elf \
        -rsf $(RSF_FILE) \
        -target t \
        -exefslogo \
        -icon $(BUILD)/icon.icn \
        -banner $(BUILD)/banner.bnr \
        -major 1 -minor 0 -micro 0 \
        -DAPP_TITLE="$(APP_TITLE)" \
        -DAPP_PRODUCT_CODE="$(APP_PRODUCT_CODE)" \
        -DAPP_UNIQUE_ID="$(APP_UNIQUE_ID)" \
        -DAPP_ENCRYPTED=false \
        -DAPP_SYSTEM_MODE="64MB" \
        -DAPP_SYSTEM_MODE_EXT="Legacy" \
        -DAPP_CATEGORY="Application" \
        -DAPP_USE_ON_SD="true" \
        -DAPP_MEMORY_TYPE="Application" \
        -DAPP_CPU_SPEED="268MHz" \
        -DAPP_ENABLE_L2_CACHE="false" \
        -DAPP_VERSION_MAJOR="1" \
        -DAPP_ROMFS="$(ROMFS)"

The .bnr and .icn extensions: the files produced by bannertool use the .bnr (banner) and .icn (icon) extensions. Some tutorials use .bin, but the correct extensions are .bnr and .icn, that is what the reference buildtools do.

13. Common pitfalls in 3DS homebrew development

I walked into every one of these while building 2048-3DS, and I would have loved to have this list from day one. Here are the most frustrating mistakes and how to avoid them, it should save you a fair few hours of debugging while building your first homebrew game for the Nintendo 3DS.

VRAM artefacts: the ghost bug of the 3DS screen

Symptom: random coloured pixels appear on screen, different on every frame.

Cause: a missing C2D_TargetClear() before C2D_SceneBegin(). The 3DS VRAM is not cleared for you, it still holds the previous frame, or whatever else was lying around.

c
// WRONG — guaranteed VRAM artefacts
C2D_SceneBegin(top);
render_game();

// CORRECT — always clear before drawing
C2D_TargetClear(top, couleur_fond);
C2D_SceneBegin(top);
render_game();

linearAlloc vs malloc: the 3DS audio memory trap

Symptom: the sound is silent, or comes out as noise.

Cause: audio buffers on the 3DS must be allocated with linearAlloc(). Memory returned by malloc() is not visible to the DSP processor. You also have to release it with linearFree(), not free().

c
// WRONG — the DSP cannot read this memory
u8 *audio_buf = malloc(size);

// CORRECT — linear memory the DSP can reach
u8 *audio_buf = linearAlloc(size);
// ... use it ...
linearFree(audio_buf);

Forgetting DSP_FlushDataCache: corrupted audio on the 3DS

Symptom: the sound is corrupted, out of sync, or completely wrong.

Cause: the CPU cache and the DSP memory on the 3DS are not coherent. You have to flush the cache explicitly after writing into an audio buffer:

c
fread(snd->data, 1, chunk_size, f);
DSP_FlushDataCache(snd->data, snd->size);  // MANDATORY!

Wrong audio format: total silence

Symptom: no sound at all, or a crash while loading.

Cause: the WAV file is compressed (ADPCM, an encapsulated MP3 and so on) instead of being raw PCM. The 3DS only supports uncompressed PCM. Check the file and convert it:

bash
# Check the codec of the WAV file
ffprobe music.wav 2>&1 | grep "Audio:"
# Should print: pcm_s16le (16-bit) or pcm_u8 (8-bit)

# Convert an MP3 into a 3DS-compatible PCM WAV
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 22050 music.wav

C2D_Color32 is not constexpr in C99

Symptom: the compile error “initializer element is not constant” with citro2d in C99.

Cause: C2D_Color32() is an inline function in C, not a constant expression, so it cannot initialise global variables in C99. The fix:

c
// WRONG in C99
static u32 my_color = C2D_Color32(0xFF, 0x00, 0x00, 0xFF);

// CORRECT — MAKE_COLOR macro instead of C2D_Color32
#define MAKE_COLOR(r,g,b,a) \
    ((u32)(r) | ((u32)(g)<<8) | ((u32)(b)<<16) | ((u32)(a)<<24))
static u32 my_color = MAKE_COLOR(0xFF, 0x00, 0x00, 0xFF);

The .3dsx works but the .cia crashes: RSF permissions

Symptom: the homebrew runs perfectly as a .3dsx through the Homebrew Launcher, but crashes immediately as a .cia (often with “ErrDisp: An error has occurred” or “SD Card was removed”). The same .cia works in Citra.

Cause: the .3dsx format inherits the broad permissions of the Homebrew Launcher. The .cia has its own permissions, declared in the RSF file. The Citra emulator ignores permission restrictions, which is why the .cia runs under emulation but not on hardware. Three main causes:

  • An incomplete SystemCallAccess: if an SVC used by libctru is not declared, the ARM11 kernel refuses the call and kills the process. Include every SVC from 1 to 125.
  • A missing ServiceAccessControl: if a service (dsp::DSP or gsp::Gpu, for instance) is not declared, srvGetServiceHandle() fails and the matching library crashes during initialisation. Include every service listed in section 12.
  • Logo: None in the RSF: on some firmware versions, having no logo in the ExeFS crashes the application at boot. Use Logo: Nintendo together with the -exefslogo flag.

Fix: use the complete RSF template from section 12, with the full set of SVCs, services and permissions. It is the most reliable way to get a .cia that runs on real hardware.

ndspWaveBuf not reset: the sound only plays once

Symptom: a sound effect plays correctly the first time, then never again.

Cause: once played, the status of the ndspWaveBuf stays at “done”. You have to reset the status and flush the cache again before replaying it:

c
snd->wave_buf.status = NDSP_WBUF_FREE;    // reset
DSP_FlushDataCache(snd->data, snd->size);  // flush again
ndspChnWaveBufAdd(channel, &snd->wave_buf); // play again

Forgetting romfsInit(): no asset loads

Symptom: every fopen("romfs:/...") returns NULL. Fonts, textures and sounds fail to load.

Cause: romfsInit() was never called at the start of the program. That call is mandatory before any access to the RomFS file system.

Wrong dimensions for the CIA banner assets

The dimensions are strict for the CIA format:

  • Icon: exactly 48×48 pixels, PNG
  • Banner: exactly 256×128 pixels, PNG
  • Banner audio: PCM WAV, ideally short (~3 seconds)

If the dimensions do not match, bannertool either fails silently or produces a corrupt binary that breaks the installation.

14. Conclusion: building your own homebrew game for the Nintendo 3DS

Writing a homebrew game for the Nintendo 3DS pulls in a lot of genuinely interesting areas of programming: embedded systems programming, 2D graphics with citro2d, low-level audio with NDSP, platform-specific memory management, and ARM cross-compilation. The constraints of the platform (limited memory, two screens, proprietary formats) force you to write clean, efficient code, and honestly, I had a great time rising to those challenges.

The 6 golden rules of 3DS homebrew development

  1. Separate your game logic from your rendering. Portable logic in one file, 3DS rendering in another. That is what made 2048-3DS quick to build.
  2. C2D_TargetClear before every scene. Always. No exceptions. Otherwise: VRAM artefacts.
  3. linearAlloc for audio, never malloc. And never forget DSP_FlushDataCache().
  4. PCM WAV only for every audio file. Convert your MP3s and OGGs before bundling them.
  5. Test the .cia as well as the .3dsx. The permissions differ, and one forgotten service in the RSF means a crash.
  6. Declare every system service in the RSF file, in particular dsp::DSP, csnd:SND, hid:USER and fs:USER.

The 2048 for Nintendo 3DS project puts every concept from this tutorial into practice: dual-screen rendering, smooth animations, multi-track audio, binary saves, localisation into 13 languages, an achievement system, and packaging as both .3dsx and .cia. I open-sourced it so it can serve as a starting point for your own projects.

Download 2048 for Nintendo 3DS, free, in .3dsx and .cia. Have a look at the source code to see all of these concepts at work. And if you start a 3DS homebrew project of your own, do share it, the community is welcoming and always happy to lend a hand.

Resources for 3DS homebrew development

  • devkitPro: https://devkitpro.org/, the official toolchain for 3DS homebrew development
  • libctru documentation: https://libctru.devkitpro.org/, the complete libctru API reference
  • citro2d headers: the header files in $DEVKITARM/../libctru/include/citro2d/ are the best documentation for the graphics library
  • 3dbrew Wiki: https://www.3dbrew.org/, the exhaustive technical wiki for the Nintendo 3DS (hardware, formats, system services)
  • Official examples: https://github.com/devkitPro/3ds-examples, samples covering graphics, audio and networking
  • bannertool: https://github.com/Steveice10/bannertool, banner and icon generator for the CIA format
  • makerom: https://github.com/3DSGuy/Project_CTR, CIA/CCI file builder for the Nintendo 3DS

Nintendo 3DS technical specifications

  • CPU: ARM11 MPCore (ARMv6K) @ 268 MHz, 2 cores (4 on New 3DS)
  • GPU: DMP PICA200 @ 268 MHz
  • RAM: 128 MB (256 MB on New 3DS), including 6 MB of dedicated VRAM
  • Top screen: 400×240 pixels (800×240 in stereoscopic 3D mode)
  • Bottom screen: 320×240 pixels, resistive touch
  • Audio: 24 DSP channels, stereo output
Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.