CRM64Pro Tutorial 16.4

Scene Shooter.

Build a vertically scrolling shooter with TMX-created entities, player and enemy projectiles, broad- and narrow-phase collision, explosions, camera shake, and a HUD.

Final result

CRM64Pro Tutorial 16.4 Scene Shooter
Runtime controls

Cursor keys move the ship; Space shoots; S toggles smooth scrolling; D toggles object bounds; and Q or ESC exits.

The object-bound toggle targets standard gameplay objects without changing Scene or layer overlays.

Prerequisites

  • CRM64Pro GDK installed and configured with a supported C++17 compiler.
  • Tutorial package downloaded and fully extracted, preserving its folder structure.
  • Understand TMX object layers and Scene callbacks.
  • Included assets available under bin/Base/: tiled/ship/shooter.tmx, tiled/ship/ship.cdc.
  • Keep Tutorial_bg in Tutorial.cdc.

What you will learn

  • Create typed gameplay objects through a Scene factory.
  • Own a child sprite from each Scene object.
  • Spawn and remove runtime projectiles safely.
  • Combine spatial and pixel-perfect collision checks.
  • Configure repeating autoscroll layers.

Step 1: Keep shared IDs in one state

Callbacks keep the Scene, HUD resources, and stable object IDs together.

struct TutorialState
{
    Scene* pScene = nullptr;
    Sint32 idBgImage = -1;
    Sint32 idHUDFont = -1;
    bool bDebug = false;
    Sint32 idPlayer = -1;
    Sint32 idEnemy1 = -1;
    Sint32 idEnemy2 = -1;
};

Step 2: Own the entity sprite

Every gameplay object owns one child sprite and releases it with the object.

static Sint32 spawnSpriteChild(Sint32 iBaseSprite)
{
    if(iBaseSprite < 0) return -1;
    return Main::instance().spriteMgr().child(iBaseSprite);
}

virtual ~GameEntity()
{
    if(iIDSprite >= 0)
    {
        Main::instance().spriteMgr().close(iIDSprite);
        iIDSprite = -1;
    }
}

Step 3: Replace destroyed ships with explosions

The old child is closed first. If the explosion cannot be created, the object is removed cleanly.

void GameEntity::die(Scene* pScene, SceneLayerContext& rContext)
{
    if(bIsDead) return;
    bIsDead = true;

    // Switch to a per-entity explosion child sprite
    if(iIDSprite >= 0)
    {
        Main::instance().spriteMgr().close(iIDSprite);
        iIDSprite = -1;
    }
    iIDSprite = spawnSpriteChild(Explosion::iIDSpriteBase);
    Sprite* pS = Main::instance().spriteMgr().get(iIDSprite);
    if(!pS)
    {
        SceneLayerObject* pLayer = pScene ? pScene->accessLayerObject(rContext.idLayer) : nullptr;
        if(pLayer) pLayer->closeObject(getID());
        return;
    }
    pS->selectAnimIndex(0);
    pS->resume();
}

Step 4: Initialize runtime projectiles once

A projectile is initialized before placement. The guard prevents Scene insertion from creating a second child sprite.

void initialize() override
{
    if(iIDSprite >= 0) return;
    GameEntity::initialize();
    iIDSprite = spawnSpriteChild(bIsEnemy ? iIDLaserEnemyBase : iIDLaserBase);
    Sprite* pS = Main::instance().spriteMgr().get(iIDSprite);
    if(pS)
    {
        pS->selectAnimIndex(0);
        Sint32 iW, iH;
        pS->getOffset(&iW, &iH);
        setSize((float)iW, (float)iH);
    }
    else setSize(16.0f, 16.0f);
}

Step 5: Spawn player shots safely

The layer takes ownership only after addObject() succeeds; every earlier failure deletes the projectile locally.

Laser* pL = new(std::nothrow) Laser();
if(pL)
{
    pL->initialize();
    if(pL->iIDSprite < 0)
    {
        delete pL;
        return;
    }
    float fLaserX = getX() + (getWidth() / 2.0f) - (pL->getWidth() / 2.0f);
    pL->setPosition(fLaserX, getY() - pL->getHeight());

    SceneLayerObject* pLayer = g_pState->pScene->accessLayerObject(2);
    if(!pLayer || pLayer->addObject(pL) < 0)
    {
        delete pL;
        return;
    }
    iShotCooldown = iShotCooldownTicks;
}

Step 6: Use broad- and narrow-phase collision

The object layer finds nearby candidates, AABBs reject misses, and sprite overlap confirms visible hits.

vector<SceneObject*> vNeighbors;
pLayer->queryRegion(getX(), getY(), getWidth(), getHeight(), vNeighbors);

for(auto* pObj : vNeighbors)
{
    if(pObj == this) continue;

    bool bHit = false;
    if(!bIsEnemy && pObj->getType() == "enemy") bHit = true;
    else if(bIsEnemy && pObj->getType() == "player") bHit = true;

    SDL_FRect rOverlap;
    if(bHit && overlapsObject(pObj, &rOverlap))
    {
        GameEntity* pTarget = dynamic_cast<GameEntity*>(pObj);
if(pS1 && pS2)
{
    if(!Main::instance().physics().overlapsSprite(pS1, getX(), getY(), pS2, pTarget->getX(), pTarget->getY(), &rOverlap))
    {
        continue; // No pixel hit, keep checking neighbors
    }
}

Step 7: Create typed objects from TMX

The TMX player and enemy types select their C++ classes.

class TutorialObjectFactory : public ISceneObjectFactory
{
public:
    SceneObject* create(const string& rType) override
    {
        if(rType == "player") return new(std::nothrow) Player();
        if(rType == "enemy")  return new(std::nothrow) Enemy();
        return nullptr;
    }
};

Step 8: Configure vertical autoscroll

Both map layers repeat vertically and share the same autoscroll speed.

for(Sint32 i = 1; i <= 2; i++)
{
    SceneCameraParams mCam;
    if(!g_pState->pScene->setLayerFeatures(i, SLF_REPEATY, true) ||
       !g_pState->pScene->getCameraParams(i, &mCam))
    {
        mC64.logMgr().get()->msg(LL_ERROR, "Failed to configure Scene layer %d.\n", i);
        closeTutorial();
        return -1;
    }
    mCam.ptAutoScrollSpeed = { fAutoScrollX, fAutoScrollY };
    if(!g_pState->pScene->setCameraParams(i, mCam) ||
       !g_pState->pScene->setCameraMode(i, SCM_AUTOSCROLL))
    {
        mC64.logMgr().get()->msg(LL_ERROR, "Failed to configure Scene layer %d.\n", i);
        closeTutorial();
        return -1;
    }
}

Step 9: Validate the map contract

The tutorial requires one factory-created player and exactly two factory-created enemies.

pPlayer = dynamic_cast<Player*>(pObjLayer->findFirstByType("player"));
vector<SceneObject*> vEnemies;
pObjLayer->findAllByType("enemy", vEnemies);
Enemy* pEnemy1 = vEnemies.size() > 0 ? dynamic_cast<Enemy*>(vEnemies[0]) : nullptr;
Enemy* pEnemy2 = vEnemies.size() > 1 ? dynamic_cast<Enemy*>(vEnemies[1]) : nullptr;
if(!pPlayer || vEnemies.size() != 2 || !pEnemy1 || !pEnemy2)
{
    mC64.logMgr().get()->msg(LL_CRITICAL, "Map must contain one player and exactly two enemies created by the object factory.\n");
    closeTutorial();
    return -1;
}
g_pState->idPlayer = pPlayer->getID();
g_pState->idEnemy1 = pEnemy1->getID();
g_pState->idEnemy2 = pEnemy2->getID();

Step 10: Update, render, and release resources

The fixed-rate Scene update drives gameplay. Cleanup follows ownership order and also covers partial setup.

// 3. Engine logic update (fixed rate by iLogicRate)
g_pState->pScene->update();
static void closeTutorial()
{
    Main& mC64 = Main::instance();
    mC64.sceneMgr().close(0);
    mC64.spriteMgr().close(0);
    mC64.fontMgr().close(0);
    mC64.imageMgr().close(0);
    Main::terminate();
}

Complete source

Previous tutorial

Add gravity, jumping and tile collision.

Go to Tutorial 16.3: Scene Platform

Tutorial index

Back to tutorials

Next tutorial

Combine terrain costs, pathfinding and fog of war.

Go to Tutorial 16.5: Scene War Game