Tutorial: A Block or Object Behaviour
Per-type facts live in two registries. This walk-through follows one object capability and one block change from the table to the code that reads it and the tests that guard it.
Decor.cpp in the sibling repository
mobile-eggbert) or an owner decision recorded in plans/audit/, and cite it in the
comment and the commit. Never renumber a block or object type.The two registries
| Registry | Keyed by | Files |
|---|---|---|
| BlockDefinitionRegistry | block type = icon in object-m.png, 0-440 | include/GalaxyEggbert/BlockDefinitionRegistry.hpp, src/GalaxyEggbert/BlockDefinitionRegistry.cpp |
| ObjectDefinitionRegistry | Def::ObjectType, 0-203 | src/GalaxyEggbert/Game/ObjectDefinitionRegistry.hpp, .cpp |
Both are built once in their constructor, are free of CNA types, and are read through
GetBlockDefinition(type) and GetObjectDefinition(type). Consumers must not keep
parallel lists of type numbers; that was the point of centralising them (July 2026) and of the final object
audit, which left only distinct state machines and explicitly scoped cheat commands as code branches.
Part 1: an object capability flag
The hanging mine (ObjectType 3) kills on contact like the other hazards, except that a
crouching Blupi passes under it: the original's MoveObjectDetect skips that type while Blupi's
action is Down. In Galaxy that difference is one flag.
The table
In ObjectDefinitionRegistry.cpp the shared contact-hazard list and its exception:
ForTypes(definitions_, {2, 3, 4, 16, 17, 20, 96, 97},
[](ObjectDefinition& definition)
{
definition.genericContactHazard = true;
});
...
definitions_[3].genericContactHazardCrouchImmune = true;
ForTypes(definitions_, {3, 16, 96, 97}, [](ObjectDefinition& definition)
{
definition.balloonPoppableHazard = true;
});
The consumer
One place reads it, the generic hazard contact in
src/GalaxyEggbert/Game/InteractionSystem.cpp:
if (GetObjectDefinition(obj.type).genericContactHazard)
{
if (blupiCrouching &&
GetObjectDefinition(obj.type).genericContactHazardCrouchImmune)
{
continue;
}
...
if (!blupiInvincible && !contactGrace_ && touches)
{
if (blupiBallooned &&
GetObjectDefinition(obj.type).balloonPoppableHazard)
{
events_.push_back(Event{EventKind::BalloonPopped});
...
Note the shape: InteractionSystem does not touch the controller, the camera or the HUD; it
pushes an event and the host reacts. Keep new behaviour on that side of the boundary.
The guard
tools/VerifyDefinitionRegistries.cpp keeps an independent expectation for every flag and checks
all 204 types against it:
bool ExpectedGenericContactHazardCrouchImmune(int raw)
{
return raw == 3;
}
...
Check(definition.genericContactHazardCrouchImmune ==
ExpectedGenericContactHazardCrouchImmune(raw),
"generic contact hazard crouch immunity is centralized without parity loss");
So a registry change fails this test until you also change the expectation, which is deliberate: the two
edits together are the reviewable statement "type X now behaves like this". Then add a behavioural check
(in VerifyInteractionSystem, which runs InteractionSystem against
world999) that fails without your change.
If the change touches animation
An object's sprite frames per phase come from GetObjIcon
(src/GalaxyEggbert/Game/ObjectIcons.cpp). VerifyGetObjIcon locks every frame array and
divisor, and VerifyObjIconAgainstReferenceDoc cross-checks them against the tables in
mobile-eggbert-reference/08-animations.md. Where a 3D model replaces the sprite, the pose comes from
that model's icons.txt (generated; see Extending
the Game).
Part 2: a block becomes solid in 3D
A block's collision mask is derived from the original's quarter-cell table
(table_decor_quart, in DecorQuartTable.hpp). Some tiles that the original's Blupi
walks in front of in 2D (columns, girders, posts) stand in his way in 3D. The owner decided they are solid
(owner report OR-0069; the commit of 2026-11-04 cites it by its earlier number OR-0042). That decision is a
named list in the header:
inline constexpr std::array<std::uint16_t, 12> kSolidIn3DDecor{
49, 50, 51, 76, 77, 199, 200, 201, 235, 236, 300, 398};
and one loop in the registry's constructor:
// Solid in 3D (OR-0069, kSolidIn3DDecor): a whole block each, as the fans (M3D-0004). Drawn
// as before (mobileTransparent is the original's). The secret passages stay passable.
for (const auto icon : kSolidIn3DDecor)
{
definitions_[static_cast<std::size_t>(icon)].collisionMask = 0xFFFF;
}
Who picks it up
BlupiControllertests collision againstcollisionMask, and a tile with a full mask is no longer passabledecor(the registry derivesdecorfrom an empty mask and no role), so Blupi stands on it and is stopped by it.- The renderer is unchanged: drawing follows the render mode, not the mask.
- The Python checkers read
tools/data/blocks.csv(columnscollision_maskandsolid), solevelcheck.pysees the new walls only after the CSV is refreshed.
Refresh the CSV and run the guards
cmake --build build-cna-opengl3 --target DumpBlockDefinitions VerifyDefinitionRegistries
build-cna-opengl3/DumpBlockDefinitions > tools/data/blocks.csv # from the repository root
ctest --test-dir build-cna-opengl3 -R "VerifyBlockDefinitionsCsv|VerifyDefinitionRegistries|VerifyBlupiMovement"
VerifyBlockDefinitionsCsv runs DumpBlockDefinitions --check tools/data/blocks.csv
and fails while the committed CSV differs from the C++ table. VerifyDefinitionRegistries recomputes
every mask from the quarter-cell table plus kSolidIn3DDecor.
Then the worlds
A tile that becomes solid can close a route a world relied on. The real commit (2d9cdaf7,
feat(gameplay): the decor that stands in Blupi's way in 3D is solid, the secret passages stay
passable) changed nine files together: the registry header and source, the ledger row, a new
VerifyBlupiMovement case (Blupi stands on a floor of girders, still falls through a cobweb), the
registry test's expectation, the regenerated blocks.csv, one world generator and its
.vwr (world058), and one playthrough (world056.txt). Run the world checks
and the playthroughs (Testing & QA, "Which checks for which
change") to find yours.
Checklist
- Evidence first: the original's code, or a ledger row / owner decision for a 3D adaptation.
- Change the registry entry, not a consumer; update its comment with the citation.
- Update the expectation in
VerifyDefinitionRegistries; add a behavioural test that fails without the change. - Block change: rebuild and regenerate
tools/data/blocks.csv. - Run ctest, the golden trace, the affected missions' playthroughs and
tools/verify_demos.sh; a world that no longer works is fixed in its generator. - Record the change in its ledger row and commit everything together, the id in the subject.