Skip to main content

NSMB Co-op Development Guide

NSMB Co-op Development Guide

Introduction

This guide explains how to write code that works correctly with the NSMB Co-op hack. The co-op system allows two players (Mario and Luigi) to play simultaneously on separate consoles connected via local wireless.

The biggest challenge in co-op development is preventing desyncs - situations where the two consoles have different game states. This document covers common desync patterns and how to avoid them, along with co-op-specific systems like player spectating.

Key Principle: Any code that affects gameplay state must produce identical results on both consoles, regardless of which console is running it.

Table of Contents

  1. Core Concepts - Understanding desyncs and co-op fundamentals
  2. Common Anti-Patterns to Avoid - Quick reference of what NOT to do
  3. Understanding Desyncs: A Detailed Example - Step-by-step desync analysis
  4. Co-op-Safe Patterns and Solutions - Practical coding techniques
  5. Special Cases - Exceptions and edge cases
  6. Debugging and Troubleshooting - Tools and techniques for finding issues
  7. Advanced Systems - Player spectating and complex features

Core Concepts

What is a Desync?

A desync occurs when the two consoles have different game states. For example:

  • Console 1 thinks Mario has 3 lives, Console 2 thinks Mario has 2 lives
  • Console 1 shows an enemy as alive, Console 2 shows it as defeated
  • Console 1 has a different random number sequence than Console 2

Safe vs. Unsafe Operations

  • Safe: Operations that only affect local display/audio (sounds, UI elements, screen shaking)
  • Unsafe: Operations that modify gameplay state (player health, enemy behavior, item spawning, particle effects)

The Golden Rule

When writing gameplay logic, always consider: "What happens if both consoles run this code at the same time?"

Common Anti-Patterns to Avoid

Before diving into specific solutions, here are the most common mistakes that cause desyncs:

❌ DON'T: Use Game::localPlayerID for gameplay logic

// This will desync!
if (shouldTriggerEvent()) {
    Player* player = Game::getPlayer(Game::localPlayerID);
    player->giveReward();
}

✅ DO: Loop through all players or use linkedPlayerID

// This stays in sync!
if (shouldTriggerEvent()) {
    for (s32 playerID = 0; playerID < Game::getPlayerCount(); playerID++) {
        Player* player = Game::getPlayer(playerID);
        if (playerMeetsCondition(player)) {
            player->giveReward();
        }
    }
}

❌ DON'T: Use ViewShaker without playerID parameter

// This will desync!
ViewShaker::start(type, viewID);

✅ DO: Specify which player should feel the shake

// This stays in sync!
ViewShaker::start(type, viewID, playerID, false);

❌ DON'T: Use Game::getRandom() for gameplay logic

// This will desync!
if ((Game::getRandom() & 0xFF) == 0) {
    spawnEnemy();
}

✅ DO: Use Net::getRandom() for synchronized randomness

// This stays in sync!
if ((Net::getRandom() & 0xFF) == 0) {
    spawnEnemy();
}

Understanding Desyncs: A Detailed Example

Let's examine how a typical desync occurs using a Goomba collision example:

Let's pretend this is how a Goomba is coded to hurt a player:

void Goomba::hurtPlayer() {
    // Game::localPlayerID is the ID of the player for *our* console
    s32 playerID = Game::localPlayerID;

    // Game::getPlayer(id) gives us a pointer to a Player object
    // id = 0 → Mario
    // id = 1 → Luigi
    Player* player = Game::getPlayer(playerID);

    // The local player gets hurt
    player->getHurt();

    // Problem:
    // On Console 0 → local_player_id = 0 → only Mario gets hurt
    // On Console 1 → local_player_id = 1 → only Luigi gets hurt
    //
    // Bad result:
    // Console 0 sees:
    //   Mario = HURT
    //   Luigi = NOT HURT
    //
    // Console 1 sees:
    //   Mario = NOT HURT
    //   Luigi = HURT
}

The Solution

The fix is to use the collision information that's already available:

Usually when a collision with an enemy occurs, the actor is informed of which player collided with it. This information is stored in this->linkedPlayerID.

void Goomba::hurtPlayer() {
    // this->linkedPlayerID is the ID of the player that collided with the Goomba
    // Let's assume it was Mario (0)
    s32 playerID = this->linkedPlayerID;

    // Game::getPlayer(id) gives us a pointer to a Player object
    // id = 0 → Mario
    // id = 1 → Luigi
    Player* player = Game::getPlayer(playerID);

    // The player that collided with the Goomba gets hurt
    player->getHurt();

    // Good result:
    // Console 0 sees:
    //   Mario = HURT
    //   Luigi = NOT HURT
    //
    // Console 1 sees:
    //   Mario = HURT
    //   Luigi = NOT HURT
}

Alternative: Player Loop Pattern

When this->linkedPlayerID isn't available, use the player loop pattern:

bool Goomba::shouldPlayerGetHurt(Player* player) {
    // ... do any checks to decide if Player should get hurt

    // In this example we assume Mario (0) is in love with the Goomba
    return player->isInLoveWithGoomba(this);
}

void Goomba::hurtPlayer() {
    // Update the logic for all players
    for (s32 playerID = 0; playerID < Game::getPlayerCount(); playerID++) {
        // Game::getPlayer(id) gives us a pointer to a Player object
        // id = 0 → Mario
        // id = 1 → Luigi
        Player* player = Game::getPlayer(playerID);

        if (shouldPlayerGetHurt(player)) {
            // The player that collided with the Goomba gets hurt
            player->getHurt();
        }
    }

    // Good result:
    // Console 0 sees:
    //   Mario = HURT
    //   Luigi = NOT HURT
    //
    // Console 1 sees:
    //   Mario = HURT
    //   Luigi = NOT HURT
}

Co-op-Safe Patterns and Solutions

Player Targeting: Finding the Right Player

Use ActorFixes_getClosestPlayer(this) instead of Game::getLocalPlayer() or Game::getPlayer(Game::localPlayerID).

void Volcano::spawnMeteor() {
    // BAD: Always targets the local player
    Player* target = Game::getLocalPlayer();

    // GOOD: Finds the closest player to the volcano
    Player* target = ActorFixes_getClosestPlayer(this);

    // Spawn meteor at target's position
    Vec3 meteorPos = target->position;
    Actor::spawnActor(METEOR_ID, 0, &meteorPos, nullptr, nullptr, nullptr);
}

For zone-specific targeting:

void SpikeBass::attack() {
    // Find the closest player in a specific zone
    Player* target = ActorFixes_getClosestPlayerInZone(this, zoneID);
    if (target == nullptr) {
        // Fallback to any closest player
        target = ActorFixes_getClosestPlayer(this);
    }

    // Attack the target
    fireProjectileAt(target->position);
}

Audio: Console-Specific Sound Effects

This is one of the few cases where it's safe to use Game::localPlayerID, as audio is local to each console - the other console doesn't receive or process your sound effects.

void MyHack::updatePlayerFlyState() {
    // Update the logic for all players
    for (s32 playerID = 0; playerID < Game::getPlayerCount(); playerID++) {
        Player* player = Game::getPlayer(playerID);

        // ... logic to update the fly state

        // Play flight finished jingle
        if (player->finishedFlying) {
            // Only the player that finished flying will hear the jingle
            if (playerID == Game::localPlayerID) {
                SND::playSFX(FLIGHT_FINISHED_SFX, &player->position);
            }
        }
    }
}

Another common pattern is to play sound effects when items are collected or power-ups are switched:

void RedRing::spawnReward() {
    for (s32 playerID = 0; playerID < Game::getPlayerCount(); playerID++) {
        Player* player = Game::getPlayer(playerID);

        // Determine reward based on power-up
        PowerupState reward = calculateReward(player->currentPowerup);

        // Play sound only for the local player when they get Fire Flower
        if (reward == PowerupState::Fire && playerID == Game::localPlayerID) {
            SND::playSFX(0x17E, &this->position);
        }

        // Spawn the item for this player
        spawnItemForPlayer(reward, playerID);
    }
}

Safe Uses of Game::localPlayerID

There are specific cases where using Game::localPlayerID is not only safe, but necessary:

  1. Sound Effects: Audio is local to each console
  2. Visual UI Elements: Screen-specific UI components like menus and HUD
  3. Liquid Position: Due to co-op forcing shared areas, liquid levels are stored per-console
  4. File Loading: Loading graphics or UI resources that are console-specific
// ✅ SAFE: Sound effects
if (playerID == Game::localPlayerID) {
    SND::playSFX(soundID, &position);
}

// ✅ SAFE: Liquid collision (special case - see liquid section)
if (player->position.y < Stage::liquidPosition[Game::localPlayerID]) {
    // Handle liquid damage
}

// ✅ SAFE: UI updates
if (playerID == Game::localPlayerID) {
    FS::loadFileLZ77(spectateTextFileID, (u16*)HW_OBJ_VRAM);
}

View Shaking: Per-Player Screen Effects

If you want to shake the screen for a specific player, never use the basic ViewShaker::start overloads with conditional logic:

// BAD: This causes instant desync
if (canShakePlayer(Game::localPlayerID)) {
    ViewShaker::start(type, viewID);
}

This causes an immediate desync. Instead, use the 4-argument overload without the conditional check:

void SledgeBro::doGroundPound() {
    for (s32 playerID = 0; playerID < Game::getPlayerCount(); playerID++) {
        Player* player = Game::getPlayer(playerID);

        // Check if this specific player should be affected
        if (ActorFixes_isPlayerInShakeRange(player)) {
            ViewShaker::start(3, this->viewID, playerID, false);

            // Play sound only for the local player
            if (playerID == Game::localPlayerID) {
                SND::playSFX(138, &this->position);
            }

            // Apply gameplay effects to this specific player
            if (!Game::getPlayerDead(playerID)) {
                player->takeDamage();
            }
        }
    }
}

Camera and Visibility Checks

Never use Game::isOutsideCamera(..., Game::localPlayerID) for gameplay logic. Use ActorFixes_isOutsideCamera or ActorFixes_isInRangeOfAllPlayers instead:

void Enemy::updateBehavior() {
    // BAD: Only checks against local player's camera
    if (Game::isOutsideCamera(this->position, boundingBox, Game::localPlayerID)) {
        return; // Skip update
    }

    // GOOD: Checks against the closest player's camera
    if (ActorFixes_isOutsideCamera(this, boundingBox)) {
        return; // Skip update
    }

    // Continue with enemy logic...
}

For entities that need to stay active when any player can see them:

void Enemy::onUpdate() {
    // This ensures the actor only updates if ANY player can see it
    if (!ActorFixes_isInRangeOfAllPlayers(this)) {
        return; // All players are too far away, skip update
    }

    // Continue updating since at least one player can see us
    updateLogic();
}

Rendering Optimization

Use ActorFixes_safeSkipRender for 3D animated entities that need to update their models but may not render:

class HammerBro : public StageEntity3DAnm {
    bool skipRender() override {
        // This will update the model but only render for players who can see it
        return ActorFixes_safeSkipRender(this);
    }
};

Random Number Generation

Use Game::getRandom() for local code (UI, effects, sounds). Use Net::getRandom() for gameplay logic that affects game state:

void Blockhopper::updateJump() {
    // BAD: Different random numbers on each console = desync
    if ((Game::getRandom() & 0xFF) == 0) {
        doJump();
    }

    // GOOD: Synchronized random numbers across consoles
    if ((Net::getRandom() & 0xFF) == 0) {
        doJump();
    }
}

Special Cases

Liquid/Lava Damage: The Exception to the Rule

Special Case: Liquids are one of the few exceptions where you DO use Game::localPlayerID!

This is because the co-op implementation doesn't support per-player liquid levels - if liquid is detected in the level, both players are forced to always be in the same area, so they share the same liquid level. The liquid position is managed per-console, not per-player.

void checkLiquidDeath(Player* player) {
    s32 playerID = player->linkedPlayerID;

    // CORRECT: Use localPlayerID for liquid position
    // Both players share the same liquid level since they're in the same area
    if (player->position.y < Stage::liquidPosition[Game::localPlayerID]) {
        player->playSFXUnique(338, &player->position);
        Liquid_doWaves(player->position.x, 1);
        Game::losePlayerLife(playerID);
        Game::setPlayerDead(playerID, true);
    }
}

The reason for this exception:

  • Liquid positions are stored per-console: Stage::liquidPosition[Game::localPlayerID]
  • Co-op forces both players to be in the same area at all times
  • Each console only tracks one liquid level (the local one)
  • Trying to use Stage::liquidPosition[playerID] would fail because only index [Game::localPlayerID] is valid

Debugging and Troubleshooting

Desync Detection System

The codebase includes a DesyncGuard system that helps detect when the game state diverges between consoles. Key events that are monitored include:

  • Player damage events
  • Power-up changes
  • Scene transitions
  • RNG usage

If you're adding new gameplay systems, consider adding desync check markers at critical points:

void myGameplayFunction() {
    // Your gameplay logic here

    // Mark that this function was called to detect desyncs
    DesyncGuard::markDesyncCheck();
}

General Debugging Tips

Remember: When in doubt, loop through all players and apply logic based on each player's individual state rather than assuming anything about the local player!

Key Questions to Ask:

  • Does this code behave differently on Console 0 vs Console 1?
  • Am I using Game::localPlayerID for gameplay logic?
  • Are my random numbers synchronized between consoles?
  • Will both consoles execute this logic identically?

Advanced Systems

Player Spectate System

The co-op hack includes a spectate system that allows dead players to watch the other player and automatically follow them through level transitions. This system maintains engaging co-op gameplay when one player dies, rather than forcing a restart or breaking the co-op experience.

How Spectating Works

When a player dies in co-op mode, instead of immediately respawning or ending the level, they enter spectate mode:

  1. Target Assignment: The dead player's camera follows the living player

    // playerID ^ 1 gives us the other player (0 becomes 1, 1 becomes 0)
    PlayerSpectate::setTarget(deadPlayerID, deadPlayerID ^ 1);
    
  2. Camera Following: The spectating player's camera smoothly lerps to follow their target

    // Camera position updates to match the target player
    Player* target = PlayerSpectate::getTargetPlayer(spectatorPlayerID);
    target->followCamera(spectatorPlayerID);
    
  3. View Transitions: When the living player enters doors/pipes, spectators automatically follow

    // All spectators following transitPlayerID will switch views too
    PlayerSpectate::syncSpectatorsOnViewTransition(transitPlayerID);
    

System Components

  • Target Tracking: Each player has a target they're spectating (playerTarget[playerID])
  • Local Target Cache: Quick access to who the local player is watching (localTarget)
  • Smooth Transitions: Camera lerping prevents jarring jumps when switching targets
  • Shared Camera Mode: Special mode for certain levels (like boss fights) where both players share one camera view
  • Entrance Following: Spectators automatically transition to new areas when their target does

API Reference

// Check if a player is spectating someone else
bool PlayerSpectate::isSpectating(u32 playerID);

// Get who a player is currently spectating
Player* PlayerSpectate::getTargetPlayer(u32 playerID);

// Manually set spectate target
PlayerSpectate::setTarget(spectatorID, targetPlayerID);

// Enable smooth camera transitions
PlayerSpectate::setLerping(playerID, true);

Spectate Mode Triggers

Entering Spectate Mode:

  • Player Dies (Other Alive): Player dies and other player is still alive → Enter spectate mode
  • Level Start (No Lives): Player is dead when spawning (e.g., at level start with 0 lives)
  • Warp Cannon: When using warp cannons, all other players spectate player 0 during the shooting sequence
  • Boss Victory Cutscenes: Players not directly involved in the victory sequence spectate the player who triggered it
  • Flagpole: (NOT IMPLEMENTED YET) When one player hits the flagpole, the other player spectates them during the victory sequence

Exiting Spectate Mode:

  • Player Respawns: The player spawns back into the level → Respawned player returns to normal
  • New Level: New level starts → Spectate targets reset to self (PlayerSpectate::clearSpectators())
  • Cutscene End: Boss introduction cutscenes end → Players return to normal

Camera Lerping System

The spectate system includes smooth camera transitions via lerping (linear interpolation):

Automatic Lerp Activation:

  • Boss Victory: When players miss cutscenes or are repositioned during boss victories
  • Flagpole Hit: (NOT IMPLEMENTED YET) Smooth transition when other player hits the flagpole

Automatic Lerp Deactivation: The lerping system automatically stops itself when transitions complete:

// Position lerping stops when camera reaches close enough to target
if (distanceX < 48fx && distanceY < 48fx) {
    if (distanceX == 0 && distanceY == 0)
        playerLerping[playerID] = false; // Auto-disable
}

// Zoom lerping stops when zoom difference is eliminated
if (distance == 0)
    playerLerpingZoom[playerID] = false; // Auto-disable

Key Lerping Properties:

  • Smooth Movement: Camera doesn't snap instantly to new positions
  • Distance Thresholds: Lerping ends when within 48 units of target position
  • Separate Zoom: Zoom lerping is handled independently from position lerping
  • Self-Terminating: No manual intervention needed - lerping automatically stops when complete

The spectate system ensures that co-op gameplay remains engaging even when one player dies, allowing them to continue following the action and automatically rejoin when appropriate.

Summary

This guide covered the essential principles for writing co-op compatible code in NSMB:

Remember these key points:

  • Never use Game::localPlayerID for gameplay logic (except liquids and local-only effects)
  • Always loop through all players or use this->linkedPlayerID for collision-based logic
  • Use Net::getRandom() for synchronized randomness, Game::getRandom() for local effects only
  • Specify player IDs explicitly in functions like ViewShaker::start()
  • Use co-op-safe helper functions like ActorFixes_getClosestPlayer()

When in doubt:

  • Ask yourself: "Will this code behave identically on both consoles?"
  • Test your changes with two consoles to verify synchronization
  • Use the debugging tools and desync guards to catch issues early

Following these patterns will help ensure your code works seamlessly in the co-op environment while maintaining the engaging two-player experience.