Repository navigation
Game Hierarchy
Status: scaffold (Phase 0). This page is the single source of truth for the World/Game hierarchy (SP Campaign / MP COOP) and for the boundary between the AngelTech engine base and the Angels95 game layer. It records the target design; the Phase-0 pass only lays down the spec, the content scaffold and inert code stubs, so read the Roadmap section for what is actually implemented.
Related: Wiki/Engine-Overview.md, Wiki/LightningScript.md,
Wiki/World-Format-OZONE.md, Wiki/Engine-Roadmap.md.
The engine already has the right primitives but no hierarchy and no boundary:
-
No Game/Campaign object.
GameType(11 modes incl.CAMPAIGN,COOPERATIVE) is resolved and consumed only by the server (Source/Server/GameState.cppinit_worlds); the client reads a single rule (LevelSettings.respawnTime). Campaign/Coop exist as enum rows and UI labels. -
No event/trigger/objective system. Hooks are ad-hoc string
RunAction("on_xxx")calls hardcoded at ~20 C++ sites (Source/Script/LightningEntityManager.cpp,Source/Pawn/OzPawnSystem.cpp,Source/Main.cpp).Wiki/Engine-Roadmap.mdlists "Event bus" as a gap. -
Gameplay is hardcoded in the engine: pawn
.cfgparsing (Source/Core.hppAngelTechInit), item effects (Source/Pawn/Items.hpp), weapon/spell discriminators (LightningEntityManager::FireSelectedWeapon/CastSpell), the zone-physicsswitch(Source/Main.cpp), skill bonuses, the equipment-slot table, 3-death game-over, and the portal "campaign" globals. -
Worlds are flat.
GameData/Worlds/<Name>/World.ozonewithportal targetWorldlinks; no ordering, no mission/mode binding, no graph.
- The engine knows worlds, entities, triggers, objectives, events, rulesets —
never Sefirot, souls, guilds. Game content lives in
.ozls+ OZONE. - Gameplay is authored data plus a thin game module (
Source/Game/). -
One stat source. Unify
.ozlsEntityDefand PawnDef.cfginto the single def system (Phase 5). -
Extend, don't special-case. New world/gameplay concepts are boxed types +
bus events + opcodes, never a new
if (type == X)branch. - Additive and backwards-compatible. Existing worlds/defs keep loading; the new machinery is opt-in.
| Layer | Owns | Code |
|---|---|---|
| L0 engine core | renderer, physics, packages, OZONE, UDP transport |
Source/Renderer, Source/Physics, Source/Package, Source/World, Source/Network
|
| L1 engine systems | Script VM, BoxedType registry, EventBus, TriggerSystem, ObjectiveSystem, GameType rules |
Source/Script, Source/Events, Source/Boxed, Source/World/GameType.*
|
| L2 game layer (Angels95) | GameManager, game boxed types, game opcodes | Source/Game |
| L3 content | OZONE worlds, .ozls defs, packages |
GameData/ |
The engine may call down into L1 freely. L2 is reached only through the
narrow GameManager interface (mode + active mission + objectives), so the
engine never names a game concept.
Phase 0 registers these EntityType values (metadata-only; no runtime consumer
yet) in Source/Script/LightningEntityDef.hpp. All custom fields are authored
inside a stats { } block (the .ozls body parser only knows a fixed set of
top-level keys; unknown top-level keys warn). String values are quote-free and
space-free — use comma-separated lists with no spaces.
| Type | File | Fields (stats) |
|---|---|---|
: game |
GameData/Game/Angels95/Angels95.ozls |
modes, start_sector, save_enabled, coop_max_players, checkpoint_policy
|
: campaign |
Campaign.ozls |
game, sectors (ordered list) |
: sector |
Sectors/<Sefirah>.ozls |
numerology, theme, role, skybox, missions
|
: mission |
Missions/<Sector>/<SubSector>.ozls |
sector, world, mode, objectives, next
|
: objective |
Objectives/*.ozls |
type, target, count
|
: trigger |
Triggers/*.ozls |
topic, once, team
|
: event |
Events/*.ozls |
(actions only) |
SectorDef/MissionDef/CampaignDef/GameDef are declared raylib-free in
Source/Game/GameDef.hpp; the parser that fills them is Phase 2.
The universe is 5 macro-zones (Sefirot nodes). A Sefirah is a sector; a
sub-sector is an OZONE world (one mission each), because the engine loads one
world at a time and already links worlds via ZonePortal.
| Sector (Sefirah) | Numerology | Theme | Role | Mission worlds |
|---|---|---|---|---|
Sefirah_1_TerranPrime |
1 | terran_prime |
hub |
Sektor_01_SilentPines, Sektor_02_MegacityGrid, Sektor_03_AegisBaseAlpha
|
Sefirah_2_HydrosisPrime |
2 | hydrosis_prime |
resource |
KryoArchipel_Severny, Refinery_Complex_02B
|
Sefirah_3_Oasis333 |
3 | zeroth_desert |
open_world |
Aethelgard_Ruins, AlShaddai_Metropolis, Chasm_ForgottenMemories
|
Sefirah_4_CybrosisSubnet |
4 | cybrosis_subnet |
dungeon |
Digital_Purgatory, Phantom_Hubs, SoulEater_Vaults
|
Sefirah_5_ShatteredRealm |
5 | dmt33 |
raid |
Citadel_AstralMind, NearDeath_Threshold, Esoteric_Altar_Chambers
|
The Campaign.ozls spine orders the sectors; each : mission carries next=
so the spine can later become a graph without changing the file shape.
GameData/Game/Angels95/
Angels95.ozls # : game
Campaign.ozls # : campaign
Sectors/Sefirah_{1..5}_*.ozls # : sector
Missions/<Sector>/<Sub>.ozls # : mission (14)
Objectives/*.ozls # : objective (examples)
Triggers/*.ozls # : trigger (example)
Events/*.ozls # : event (example)
GameData/Worlds/<SubSector>/World.ozone # 14 placeholder worlds
Placeholder worlds are floor + perimeter + playerstart + one directional light
- one named sky zone; they are intentionally minimal and pass
worldcheck.
Topic-keyed publish/subscribe with a fixed payload (Event: topic, actor,
target, worldIndex, position, i0..i3, f0..f3, s0). The engine emits facts
at the existing hook sites; scripts subscribe with actions { on_<topic> }.
TriggerEntityAction becomes EventBus::Emit.
Topics: world.load/unload, zone.enter/exit, trigger.enter/exit/interact,
entity.spawn/death/damage/kill, player.spawn/death/hurt/level_up,
pickup.collect, item.use/equip, projectile.hit,
objective.progress/complete/fail, mission.start/complete, wave.start/clear,
plus the existing pawn FSM topics (patrol/chase/return/death).
New OZONE line trigger <def> minX minY minZ maxX maxY maxZ [once] [team=] →
TriggerVolume; the : trigger def declares the topic + filters. The engine
queries volumes per frame and emits trigger.enter/exit.
Authoring notes (landed with the content/editor-gaps pass): the line is a
6-number box — the parser warns about shorter lines and the runtime drops
them silently; once/team are appended after the bounds. "Inside on the
first sample" counts as an enter (m_inside starts empty), so a once
volume that contains playerstart #1 fires at load and can never fire as a
walk-in — place volumes off the spawn. AngelEd re-emits trigger lines from
the parsed entity set on save and History snapshots (there is no
trigger-placement UI — author the lines as text and they round-trip).
Keyed per-world/session state (id, progress, target, complete, failed)
with Define/Add/Set/Complete/Fail, Serialize/Deserialize. Persisted with the
world save; replicated in COOP (Phase 4).
Each world-entity type registers {name, parse, inject, update, draw, serialize};
OzOzoneLoader::InjectOzoneEntities iterates registered types instead of its
hardcoded switch. Game types register from Source/Game/GameBoxedTypes.cpp.
- Opcodes:
emit_event,objective,set_worldflag/get_worldflag,wait/timer,grant_item,spawn_projectile,apply_area_damage,give_xp,teleport,change_world,play_clip. - Conditions:
&&,||, parentheses. -
wait/timers and the richer conditions close twoEngine-Roadmap.mdgaps.
-
Per-mission rules. Bind
GameTypeInfo/MatchStateto the active mission/session instead of the current single global object that is overwritten by the last-loaded world (GameState::init_worlds). -
Session model.
{ missionId, worldIndex, mode, objectives[], party }. The server owns objective state and mission progression; clients mirror. -
Protocol (additive; no
PROTOCOL_VERSIONbump, newMessageTypes are compatible by definition):OBJECTIVE_STATE(S→C),MISSION_CHANGE/CAMPAIGN_STATE(S→C),PARTY_STATE(optional). Use the established forward-compatible tail pattern (gate on a...SizeBaseconstant). -
Spawn/respawn. Server-side
playerstartresolution and player respawn (todayspawn_world_changealways sends(0,0,0)). -
Pinned starting mission — landed.
AngelServ --mission <name>resolves the: missiondef by name, implies--campaign, and an unknown name is a startupreturn 1before any network thread is joinable (an earlyreturnwith a joinablestd::threadaborts0xC000001D).apply_starting_mission()walks the missionnext=graph and marks every earlier world'smission_skipped;GameState::IsFrontier(ws)is the one frontier predicate. Join placement (current_mission_world_index()) also walks the MISSION GRAPH, not world-list order: every unplayed mission worldIsFrontiers simultaneously (all are seeded upfront), so a list-order scan returned the alphabetically-first mission world (a LATER-sector one) and overwrote the pin at the next join — the old scan survives only as the fallback for non-hierarchy worlds and an unloaded hierarchy. The scene handshake'sactive_worldnames the same placed world asWORLD_CHANGE— the two must agree, because UDP has no ordering guarantee and whichever arrived last used to win. The client's Co-op pane (§12) and the CLI--host/--host-missionflags drive it via the sharedLaunchHostedServer()→AngelServ --campaign --mission "<name>". -
World scoping. Fix global NPCs aggroing players in another world
(
GameState::tick_npcs,part_idx == -1path).
| Hardcoded today | Target |
|---|---|
Pawn .cfg parser (Core.hpp AngelTechInit) |
: pawn .ozls defs (unify with EntityDef) |
Item effects (Source/Pawn/Items.hpp) |
on_use/on_collect scripts + generic opcodes |
Weapon/spell discriminators (LightningEntityManager) |
on_fire/on_swing/on_cast + spawn_projectile/apply_area_damage
|
Zone-physics switch (Main.cpp) |
drive from merged PhysicsInfo data |
| Skill bonuses / equipment slots | data-driven bonus/slot defs |
| 3-death game-over / respawn |
: game/: mission rules + on_player_death
|
Portal "campaign" globals (Core.hpp) |
: campaign/: mission + GameManager
|
Contact damage (Core.hpp) |
trigger/NPC event |
- New
.cppfiles are raylib-free and wired into the rootMakefile(OBJS+ a compile rule each). They are not inAngelEd/Makefileorci.ymlbecause the editor still does not use them (it consumes the schemas and type lists, not the TUs) — the three-place rule applies the day that changes. -
build-data.ps1packs per game:GameData/Game/<Game>/→System/Data/game_<Game>.ozpak(OZPK). The singlegame.ozpaklayout is gone; a loose.ozlsunderGame/is warned about at repack time. -
: game/: campaign/: sector/: mission/: objective/: trigger/: eventare classified global (not world-scoped) inLightningEntityRegistry::LoadWorldOverridesviaIsGlobalDefType— type-driven, notsourcePath-driven. Landed. - The new types are wired into AngelEd's
ScriptPaneltype→folder map andPropsPanelschemas (kGameStats/kCampaignStats/kSectorStats/kMissionStats/kObjectiveStats; keys with verified runtime consumers only —: trigger/: eventhave none and render as read-only dumps). Landed.
- The
.ozlsparser stays non-throwing and line-numbered; new types must not add a throwing read. -
EventBus/ObjectiveSystem/GameDef/BoxedTypeRegistrystay raylib-free so atest_eventssuite can link them headlessly. - Placeholder content must pass
worldcheckandworldcheck --asset-refs. - Do not renumber the on-disk
GameTypeids 0–6.
-
Phase 0 — landed. Spec (this page), content scaffold, code stubs +
build wiring,
game.ozpak. No behaviour change. -
Phase 1 — landed (engine generic).
-
EventBushas wildcard ("*") dispatch;LightningEntityManagersubscribes one bridge that turns every emitted event intoon_<topic>(dots → underscores) on the def named byEvent::s0. - Engine emits at: zone-independent
world.load, pawn FSM (entity.chase/patrol/return/death),pickup.collect,player.hurt/death, andtrigger.enter/exit. -
ObjectiveSystemgained cross-instance world flags (set_worldflag/get_worldflag). - New opcodes in
LightningScriptContext:emit_event,objective,set_worldflag/get_worldflag,give_xp,teleport,change_world,grant_item,apply_area_damage,spawn_projectile,play_clip. Drained byLightningEntityManager(engine-generic + pawn) and byCore.hpp(camera/world-dependent:teleport/change_world/apply_area_damage/spawn_projectile). - Conditions gained
&&,||and balanced parentheses. -
TriggerSystemwired end-to-end: OZONEtrigger <def> minX..maxZ [once]line →TriggerNode→TriggerSystem, queried per sim step byCore.hpp(UpdateForPoint) →trigger.enter/exiton the bus. - Tests:
tests/Events.test.cpp(bus/objectives/triggers) + newLightningScriptContextcases. -
Follow-up landed:
InjectOzoneEntitiesnow iteratesBoxedTypeRegistry(engine registers its own types there); zone enter/exit is fully on the bus (the directTriggerZoneActioncall sites are gone —zone.enter/zone.exitdispatchon_zone_enter/on_zone_exitor fall back toon_enter/on_exit, and declarative zone env is applied by the bridge);play_clipis two-leg — a forced one-shot skeletal clip on matching pawns (PawnSystem::PlayPawnClip) plus, when the command matches the held weapon def, the first-person view-model (ViewModel::PlayClip, returns to Idle); the no-pawn warn fires only when neither leg plays. The zone bridge is the SINGLE path for enter/exit: exit always emits before enter (an A→B step fireson_exit(A) thenon_enter(B)), duplicate enters are guarded on the def name (m_activeEnvZoneDef), and the exit clears it name-matched. Do not add a second emitter.
-
-
Phase 2 — core landed.
GameManagerloads the hierarchy from the registry (: game/: campaign/: sector/: mission), resolves sectors/missions, and seedsObjectiveSystemfor the active mission. Client: the Campaign title- menu pane lists sectors and their missions (immediate-mode click starts the mission), plus the/campaign [mission]console command; the active mission persists inGameData/Saves/Campaign.sav(v1: acampaign=/mission=header; legacy v0 files still load, an unknown version is rejected → a fresh start rather than a guess). Editor: the Script Manager's "New Script" type list + folder map include the new def types (Game/Angels95/{Sectors,Missions,Objectives,Triggers,Events}). Test:tests/GameManager.test.cpp. Landed: the dedicated Co-op pane (Multiplayer dropdown) — Host Co-op pins the server viaTitleMenu::GetCoopMission()→AngelServ --campaign --mission "<name>"(the--missionflag is §8), and the client's CLI flags--host/--host-mission <name>do the same from the command line (spawn + self-join 127.0.0.1:27015, skip the menu, child output appends toSystem/HostedServer.log;--hostbeats--joinwith a warning) — both paths shareLaunchHostedServer(port, missionOrNull)inCore.hpp, so the argument assembly exists once. Join Co-op is the ordinary join path; and the level picker enriches each world withmode/sector(viaGameManager::FindMissionByWorld) plus a Game Type filter that hides mode-less worlds for a specific type. -
Phase 3 — content (placeholder). The 14 sub-sector worlds are populated
with NPCs and pickups so their missions' objectives are completable; real
geometry/art is still open. Since the content/editor-gaps pass each world
also carries structural scaffolding: a
portalto itsnextmission world (6-number box + explicit target spawn +bidir, placed ~20u east of the origin spawn) and aTrigger_EnterVolumebox on the walk between them, so a fresh spawn walks east into the trigger and then the portal and the whole arc is crossable end to end. The leaf worldEsoteric_Altar_Chambershas no portal (§7.2 for the authoring traps). -
Phase 4 — COOP (substantial). The whole arc is
--campaignOPT-IN on the server; the default OFF is classic per-world co-op — no objectives seeded (neither a: missiondef nor theClearAreafallback, which arrived in the same work and is campaign content too), joiners land in world 0, and the completion below never fires. The flag lives onGameState(not argv) so unit tests pin both sides. With the flag ON: per-world rules live inWorldState(last-world-wins bug fixed; world 0 mirrors into the global match). Server loads the game hierarchy and seeds each world's objectives from its: missiondef (falling back to aClearAreaobjective); objective ids are bounded to theOBJECTIVE_STATEwire field (net::ObjectiveIdFits/net::kObjectiveIdMax, warned once at seed time — a longer id would truncate on the wire with no error anywhere). Objectives are advanced by gameplay —killon NPC death,collecton pickup — and a def'starget(NPC def / pickup name) now FILTERS that:kill 4 Walkerno longer counts a Brute, and acollectobjective no longer counts any pickup. The filtered progress is broadcast asOBJECTIVE_STATE(~1 Hz), which the client mirrors and shows in the HUD. The server reads each world's firstplayerstartand sends it inWORLD_CHANGE, and the client loads the world + applies the spawn on a server-driven switch. Completion is spatial, not a kill count. A world is complete when a player leaves it through the portal that leads to the mission'snextworld (REQUEST_WORLD); the world is marked transitioned and every player still in it moves with them. The old loop advanced the whole server the instant every objective was ticked (~5 kills of a map's 12–19 NPCs) and teleported players out of the middle of the level — the authored exits were decoys. Objectives remain the route's progress read-out, not a toll. Single-player has no server, so the client seeds the same objectives locally (GameManager::SetActiveMissionkeeps each def'stype/target) and advances them fromentity.death/pickup.collect; the HUD shows those rows when no server store exists. Missionon_start/on_completeare emitted on the bus asmission.start/mission.complete, which the LEM bridge resolves to the def's actions (the district'ssayowns the one on-screen text slot, so the OBJECTIVES row is the durable read-out). Open: party/lobby, and server-side scripted objectives (the server has no LightningScript runtime — objectives come from the defs, not from script bodies). -
Phase 5 — migration. Move §9 out of the engine; delete the
.cfgparser.
Keep this page current: when a phase lands, move its bullet to a "landed"
note and cite the file:line.
Angels95 - AngelTech Engine | repository | 7fdbf74
Built on raylib 5.5.