
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
- What a 3DS homebrew actually is
- Prerequisites: installing devkitARM and the 3DS toolchain
- Project architecture: separating game logic from rendering
- Working with the Nintendo 3DS dual screen
- 2D rendering with citro2d
- Input handling: D-pad, Circle Pad and touch screen
- 3DS homebrew audio: NDSP and PCM WAV files
- File systems: RomFS and the SD card
- Saving data to the SD card
- The 3DS Makefile: ARM cross-compilation with devkitARM
- Building a .3dsx file for the Homebrew Launcher
- Building an installable .cia file for the HOME menu
- Common pitfalls in 3DS homebrew development
- 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:
# 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/devkitARMThe 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
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:
// 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 reachedThe 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
// 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:
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
#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 heightOne 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
// 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:
// 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 textRounded 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:
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:
// 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:
# 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)// 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:
// 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
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
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:
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
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.
// 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
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:
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
// 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:
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
// 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:
// 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:
# In Makefile.3ds
ROMFS := romfsAsset 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:
// 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:
// 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:
// 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
# 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 := GekkodeARM compilation flags for the Nintendo 3DS
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#ifdefblocks that separate PC and 3DS code
Linking against citro2d and libctru
LDFLAGS = -specs=3dsx.specs -g $(ARCH) -Wl,-Map,$(notdir $*.map)
LIBS := -lcitro2d -lcitro3d -lctru -lmLibrary 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
# 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
# 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 clean11. 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 embedded2048.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:
# Defined in Makefile.3ds
APP_TITLE := 2048
APP_DESCRIPTION := 2048 puzzle game for 3DS
APP_AUTHOR := GekkodeThe 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:
_3DSXFLAGS += --romfs=$(CURDIR)/romfs12. 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
ffmpegto convert:
# Convert any audio into a bannertool-compatible WAV
ffmpeg -i music.mp3 -acodec pcm_s16le -ar 44100 -ac 2 -t 3 banner.wavBuilding 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:
# 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
BasicInfo:
Title : $(APP_TITLE)
ProductCode : $(APP_PRODUCT_CODE)
Logo : NintendoTitle: the name shown in the system settings. It uses the$(APP_TITLE)variable, substituted by-DAPP_TITLE="2048".ProductCode: an identifier in theCTR-H-XXXXform. TheHstands for homebrew. Pick a unique code (for exampleCTR-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 logoLicensed/Distributed: variants of the Nintendo logoNone: avoid this one, it can crash on some firmware versions
RomFs: embedded resources
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
TitleInfo:
Category : $(APP_CATEGORY)
UniqueId : $(APP_UNIQUE_ID)Category: alwaysApplicationfor a homebrew game or utility.UniqueId: a unique hexadecimal identifier in the0xF0000to0xFFFFFrange (the range reserved for homebrew). Every homebrew installed on the console needs a different UniqueId to avoid clashes. For 2048 I use0xF2048.
Option: packaging options
Option:
UseOnSD : $(APP_USE_ON_SD)
FreeProductCode : true
MediaFootPadding : false
EnableCrypt : $(APP_ENCRYPTED)
EnableCompress : trueUseOnSD:true(required for homebrew installed on the SD card).FreeProductCode:trueso that makerom accepts a free-form ProductCode (no Nintendo format check).EnableCrypt:false(homebrew has no Nintendo encryption keys).EnableCompress:trueto 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.
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 : trueThe settings to adapt to your own project:
MemoryType:Applicationfor a standard game or utility.Systemfor a system module.SystemMode: how much RAM is allocated.64MBis the standard. Only use96MBfor New 3DS exclusive applications that genuinely need more memory.SystemModeExt:Legacyfor Old 3DS + New 3DS compatibility.CpuSpeed:268MHzenables the clock boost on New 3DS (it falls back to 268 MHz on Old 3DS, handled automatically).EnableL2Cache:falseby default. Set it totruefor 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
FileSystemAccess:
- CategorySystemApplication
- CategoryHardwareCheck
- CategoryFileSystemTool
- Debug
- TwlCardBackup
- TwlNandData
- Boss
- DirectSdmc
- Core
- CtrNandRo
- CtrNandRw
- CtrNandRoWrite
- CategorySystemSettings
- CardBoard
- ExportImportIvs
- DirectSdmcWrite
- SwitchCleanup
- SaveDataMove
- Shop
- Shell
- CategoryHomeMenu
- SeedDBThe 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
IoAccessControl:
- FsMountNand
- FsMountNandRoWrite
- FsMountTwln
- FsMountWnand
- FsMountCardSpi
- UseSdif3
- CreateSeed
- UseCardSpiSystemCallAccess: 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:
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 : 125Worth 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:
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 conversionFor 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
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: 0x0004013000002f02SaveDataSize:0KBif 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 anLsuffix. Writing0x0004013000002402Linstead of0x0004013000002402can cause silent failures. Copy the list above exactly as it is.
IORegisterMapping and MemoryMapping
IORegisterMapping:
- 1ff00000-1ff7ffff
MemoryMapping:
- 1f000000-1f5fffff:rThese 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 flag | 2048 value | What to change |
|---|---|---|
APP_TITLE | 2048 | The name of your app |
APP_PRODUCT_CODE | CTR-H-2048 | A unique CTR-H-XXXX |
APP_UNIQUE_ID | 0xF2048 | A unique hex value in 0xF0000-0xFFFFF |
APP_ROMFS | romfs | The path to your romfs folder |
APP_SYSTEM_MODE | 64MB | 96MB if New 3DS exclusive |
APP_VERSION_MAJOR | 1 | Your 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
# 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
bannertooluse the.bnr(banner) and.icn(icon) extensions. Some tutorials use.bin, but the correct extensions are.bnrand.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.
// 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().
// 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:
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:
# 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.wavC2D_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:
// 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::DSPorgsp::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: Nintendotogether with the-exefslogoflag.
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:
snd->wave_buf.status = NDSP_WBUF_FREE; // reset
DSP_FlushDataCache(snd->data, snd->size); // flush again
ndspChnWaveBufAdd(channel, &snd->wave_buf); // play againForgetting 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
- 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.
- C2D_TargetClear before every scene. Always. No exceptions. Otherwise: VRAM artefacts.
- linearAlloc for audio, never malloc. And never forget
DSP_FlushDataCache(). - PCM WAV only for every audio file. Convert your MP3s and OGGs before bundling them.
- Test the .cia as well as the .3dsx. The permissions differ, and one forgotten service in the RSF means a crash.
- Declare every system service in the RSF file, in particular
dsp::DSP,csnd:SND,hid:USERandfs: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


