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
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_bginTutorial.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
- View Tutorial_16_Scene_Shooter.cpp
- Map:
Base/tiled/ship/shooter.tmx - Resources:
Base/tiled/ship/ship.cdc - Log:
Tutorial_16_Scene_Shooter.log
Tutorial index
Next tutorial
Combine terrain costs, pathfinding and fog of war.
