Cyberland is a cyberpunk 2D single-player RPG built in C# on .NET 8. The codebase separates a reusable engine, a thin host executable, and gameplay delivered as mods (including the shipped base game). Rendering uses Vulkan (via Silk.NET); audio uses OpenAL.
Design goals: small footprint, predictable load, and scaling from integrated GPUs to modern hardware—see .cursor/rules/cyberland-design-goals.mdc for detail.
| Requirement | Notes |
|---|---|
| .NET 8 SDK | Required to build and run. |
| Vulkan 1.x + a working driver | The host runs a 2D HDR pipeline (deferred lighting, bloom, composite to the swapchain). Init failures surface via UserMessageDialog / GraphicsInitializationException instead of crashing silently. Runtime issues use EngineDiagnostics (see Engine subsystems). |
| Windows | Primary target; input and error UI are written with that in mind (other platforms may work where Silk.NET + Vulkan do). |
From the repository root:
dotnet build Cyberland.sln -c Debug
dotnet run --project src/Cyberland.Host/Cyberland.Host.csproj -c DebugOr run via script:
.\scripts\Run-Cyberland.ps1
.\scripts\Run-Cyberland.ps1 -Watch # dotnet watch runIf PowerShell reports that the script is not digitally signed (often in an elevated shell with a strict policy), use the matching .cmd wrapper instead—it runs the same script with -ExecutionPolicy Bypass: .\scripts\Run-Cyberland.cmd, .\scripts\Publish-Cyberland.cmd, .\scripts\Sync-CyberlandAssets.cmd, .\scripts\Clear-CyberlandArtifacts.cmd, .\scripts\Setup-GitHooks.cmd. Alternatively: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser (applies only to your user), or a one-off powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Run-Cyberland.ps1.
Visual Studio Code / Cursor: default build task builds the solution; Run / Watch tasks run the host; launch configuration Cyberland.Host debugs under artifacts/bin/Cyberland.Host/debug/ so Mods/ resolves next to the executable.
Finding Run / Publish: Names like Cyberland: Run are workspace tasks, not standalone Command Palette commands—you will not see them when you only open the palette and search for “Cyberland” or “Publish”. Open the repo folder (the directory that contains Cyberland.sln and .vscode/tasks.json), then use Command Palette → Tasks: Run Task (or Terminal → Run Task…), and pick the task from the list. You can type task in the palette to jump to Tasks: Run Task quickly.
Open the Command Palette (Ctrl+Shift+P) → Tasks: Run Task → pick a Cyberland: task (same commands as the Cursor skills):
| Task | Action |
|---|---|
| Cyberland: Run | dotnet run the host (Debug) — run-cyberland skill |
| Cyberland: Test Engine | Engine tests with coverlet — test-cyberland-engine skill |
| Cyberland: Publish Release | dotnet publish (MSBuild stages Mods/ into artifacts/publish/...) — full distributable folder — publish-cyberland skill (see scripts/Publish-Cyberland.ps1) |
| Cyberland: Publish Release + distribution zip | Same as Publish Release, then writes artifacts/dist/Cyberland-Host-release.zip (archive the publish tree for upload) |
| Cyberland: Zip publish output only | Archive-CyberlandPublish.ps1 — zip an existing publish folder without rebuilding |
| Cyberland: Clear Artifacts | Delete repo-root artifacts/ — clear-cyberland-artifacts skill (see scripts/Clear-CyberlandArtifacts.ps1) |
Large game media does not live in git. Asset bundles are published in GitHub Releases and mapped by per-mod manifests:
mods/Cyberland.Game/content.release.manifest.jsonmods/Cyberland.Demo/content.release.manifest.json
Each bundle is tied to one mod and extracts into that mod's Content/ folder:
mods/Cyberland.Game/Content/mods/Cyberland.Demo/Content/
From repository root:
.\scripts\Sync-CyberlandAssets.ps1The script discovers each mod manifest, downloads release archives, verifies SHA256, and extracts to the mod-owned content folders above.
This repo includes a pre-commit hook that blocks staged files larger than 4 MiB by default, and rejects commits when any shipped demo mod manifest (mods/Cyberland.Demo*/manifest.json) is not "disabled": true in the staging area (so local testing can enable demos without that state landing in git).
.\scripts\Setup-GitHooks.ps1Override options for exceptional cases:
- One-off bypass:
git commit --no-verify - Maintainer local override:
CYBERLAND_ALLOW_LARGE_FILES=1 git commit ... - Temporary threshold override:
CYBERLAND_MAX_FILE_MB=8 git commit ... - Demo manifest override (use sparingly):
CYBERLAND_ALLOW_DEMO_MODS_ENABLED=1 git commit ...
If you bypass, follow up by moving large media into the GitHub Releases asset flow.
The solution uses .NET 8 artifacts output layout (UseArtifactsOutput + ArtifactsPath in Directory.Build.props). All compiled output, intermediates, and dotnet publish output go under artifacts/ at the repo root—not into bin/ / obj/ next to each .csproj.
| Path | Contents |
|---|---|
artifacts/bin/<ProjectName>/debug/ or .../release/ |
Built assemblies and deps (e.g. artifacts/bin/Cyberland.Host/debug/Cyberland.Host.exe). Mod staging runs on build and places Mods/ here next to the host. |
artifacts/obj/... |
MSBuild intermediate files per project. |
artifacts/publish/<ProjectName>/debug/ or .../release/ |
dotnet publish output for that project (e.g. Cyberland.Host.exe and dependencies). Mods/ is staged here by the same scripts/StageModsForHost.ps1 step that runs after publish. |
After Cyberland.Host builds, mods are staged next to the host executable under Mods/:
| Folder | Contents |
|---|---|
Mods/Cyberland.Game/ |
Base campaign mod: Cyberland.Game.dll, manifest.json, Content/ (synced from release bundles). Enabled by default (manifest.json has no disabled flag). |
Mods/Cyberland.Demo/ |
2D HDR deferred sprite + ECS sample. disabled: true in manifest.json by default — see Enabling a demo mod for testing. |
Mods/Cyberland.Demo.Pong/, ...Snake/, ...BrickBreaker/ |
Arcade samples; disabled: true by default. |
scripts/StageModsForHost.ps1 runs after build and after publish: it removes the host’s existing Mods/ folder, then for each enabled mod under mods/ copies manifest.json and Content/ (when present). Mods with an entryAssembly in manifest.json also run dotnet build on that mod’s .csproj and copy built .dll files (except Cyberland.Engine.dll). Mods that omit entryAssembly are content-only (e.g. locale or asset packs): no project build, no DLL—same behavior as ModLoader at runtime. Skips manifests with "disabled": true. That wipe prevents a mod you disabled in source from leaving an old copy next to the exe (which would still load).
Use this when you want a fresh tree or a folder you can zip and run elsewhere (framework-dependent builds still need the .NET 8 runtime on the target machine unless you publish self-contained).
-
Optional — wipe build outputs (close any running
Cyberland.Host.exefirst):if (Test-Path artifacts) { Remove-Item -Recurse -Force artifacts }
-
Publish Release from the repository root:
dotnet publish src/Cyberland.Host/Cyberland.Host.csproj -c Release
Output:
artifacts/publish/Cyberland.Host/release/(executable + dependencies). -
Sync mod media assets (if not already synced):
.\scripts\Sync-CyberlandAssets.ps1 -
Package — archive
artifacts/publish/Cyberland.Host/release/(e.g. zip that folder).Mods/is already next to the published exe.input-bindings.jsonis created at runtime next to the exe if missing.
RID-specific publish (platform-native dependencies, still uses the shared .NET 8 runtime — small download), example for Windows x64:
dotnet publish src/Cyberland.Host/Cyberland.Host.csproj -c Release -r win-x64 --self-contained falseOr use .\scripts\Publish-Cyberland.ps1 -RuntimeIdentifier win-x64 (same idea). Add -SelfContained to the script, or --self-contained true here, only when you need a larger offline bundle that does not rely on an installed runtime.
Project-specific notes for agents live in .cursor/skills/publish-cyberland/ and .cursor/skills/clear-cyberland-artifacts/.
The Cyberland.Engine.Tests project targets Cyberland.Engine only (not the host, mods, or GPU paths). It enforces 100% line coverage on that assembly via coverlet.
GitHub Actions runs this automatically in the Engine Tests workflow for pull requests and pushes to master when paths under src/Cyberland.Engine/, tests/, or related config change (plus manual workflow_dispatch).
dotnet test tests/Cyberland.Engine.Tests/Cyberland.Engine.Tests.csproj -c Debug /p:CollectCoverage=trueUse the command above for local feedback before pushing. CI remains the merge gate for engine coverage.
Coverage outputs coverage.cobertura.xml next to the test project output (ignored by git).
Types that require a real window, Vulkan, OpenAL, or Win32 MessageBox are marked [ExcludeFromCodeCoverage] (VulkanRenderer, GameApplication, OpenALAudioDevice, parts of GlslSpirvCompiler, UserMessageDialog.ShowError). When you change those, add or extend manual / integration checks; keep pure logic testable in isolation.
Cyberland.sln
Directory.Build.props # Shared SDK, language settings, artifacts output root
artifacts/ # Build outputs (gitignored): bin/, obj/, publish/
tests/
Cyberland.Engine.Tests/ # xUnit + coverlet (100% line coverage on Cyberland.Engine)
Cyberland.TestMod/ # Minimal IMod assembly used by ModLoader tests
src/
Cyberland.Host/ # Executable: references Engine only; mods staged by scripts/StageModsForHost.ps1
Cyberland.Engine/ # Engine library (ECS, Vulkan, input, mods, assets, …)
mods/
Cyberland.Game/ # Base campaign mod (IMod, locale Content/)
Cyberland.Demo/ # Sample mod (manifest disabled by default; see README)
Cyberland.Demo.Pong/ … # Arcade demos (same)
scripts/
Run-Cyberland.ps1
Publish-Cyberland.ps1
Archive-CyberlandPublish.ps1
StageModsForHost.ps1
Clear-CyberlandArtifacts.ps1
.vscode/
tasks.json, launch.json
.cursor/rules/ # Optional agent / team conventions
| Project | Role |
|---|---|
| Cyberland.Host | Entry point (Program.cs → GameApplication). References Cyberland.Engine only at compile time. scripts/StageModsForHost.ps1 (MSBuild after Build / Publish) builds each mods/*/ project and stages enabled mods into Mods/ next to the host output. |
| Cyberland.Engine | All shared runtime: windowing, Vulkan renderer, ECS, task scheduler, virtual FS, assets, localization, OpenAL, mod loader, GameHostServices. |
| Cyberland.Game | Base campaign mod → Cyberland.Game.dll (locale and future core data). Loaded by default. |
| Cyberland.Demo (and Pong / Snake / BrickBreaker) | Sample mods → respective DLLs. disabled: true in each manifest.json by default so normal runs load only the base game. |
flowchart TB
GA["GameApplication"]
WIN["Silk.NET Window"]
VK["VulkanRenderer"]
SCHED["SystemScheduler"]
ECS["World ECS"]
VFS["VirtualFileSystem"]
ML["ModLoader"]
MODS["IMod DLLs: Game and Demo"]
SYS["ISystem, IParallelSystem, ISingletonSystem"]
SVC["GameHostServices"]
INP["IInputService"]
GA --> WIN
GA --> VK
GA --> SCHED
GA --> ECS
GA --> VFS
GA --> ML
ML --> MODS
MODS --> SYS
SYS --> ECS
SYS --> SVC
SVC --> VK
SVC --> INP
- Host creates the window, graphics, input service, input bindings, ECS world, scheduler, and VFS, then calls
ModLoader.LoadAllonAppContext.BaseDirectory/Mods. - For each mod with an entry assembly,
ModLoaderrunsIMod.OnLoadAsync(to completion on the load thread) with aModLoadContext: world, scheduler, localization, VFS, andHost(GameHostServices). - Mods register systems on the scheduler and optionally spawn entities, mount extra paths, etc.
- Each presented frame, the window Render callback runs
SystemScheduler.RunFrame(world, dt)once (withdeltaSecondsfrom wall time between draws), thenVulkanRenderer.DrawFrame.RunFramewalks the same ordered list of registrations for three phases—Early (variable dt), Fixed (zero or more substeps atFixedDeltaSeconds, capped byMaxSubstepsPerFrame), Late (variable dt)—invokingIEarlyUpdate/IFixedUpdate/ILateUpdate,IParallelEarlyUpdate/IParallelFixedUpdate/IParallelLateUpdate, orISingletonEarlyUpdate/ISingletonFixedUpdate/ISingletonLateUpdate(forISingletonSystem) where implemented. TheUpdatecallback is intentionally empty so ECS does not run multiple times per draw. Input and exit behavior are handled inside mod systems (the host wiresRenderer.RequestCloseto close the window; shipped demos invoke it from input when exiting).
Rule of thumb: If it is gameplay, it belongs in a mod (or a new mod assembly), not in GameApplication.
Default engine systems (2D): The host registers systems in a fixed explicit order: parallel transform hierarchy, sprite animation, and CPU particle simulation; then ModLoader.LoadAll (mods append RegisterSerial / RegisterParallel in manifest order); then parallel CameraFollowSystem (cyberland.engine/camera-follow) for optional CameraFollow2D, parallel TriggerSystem (cyberland.engine/trigger) so trigger events reflect mod-updated fixed-step poses from the same substep, parallel CameraSubmitSystem (cyberland.engine/camera-submit) for Camera2D + Transform; then serial ViewportAnchorSystem (cyberland.engine/viewport-layout) for ViewportAnchor2D + Transform; parallel light submitters (cyberland.engine/lighting-ambient, …/lighting-directional, …/lighting-spot, …/lighting-point) over AmbientLightSource, DirectionalLightSource + Transform, SpotLightSource + Transform, and PointLightSource + Transform; parallel PostProcessVolumeSystem (cyberland.engine/post-process-volumes) for PostProcessVolumeSource; then parallel tilemap submit, serial SpriteLocalizedAssetSystem (cyberland.engine/sprite-localized-assets) for SpriteLocalizedAsset texture resolution, parallel sprite and particle submit systems; then serial TextStagingSystem (cyberland.engine/text-staging) and TextRenderSystem (cyberland.engine/text-render) for BitmapText + Transform (see TextCoordinateSpace). Prefer these ECS-driven paths over calling Submit*Light / SubmitPostProcessVolume / SubmitCamera from mod code unless you need a special-case. After VulkanRenderer initialization, GameApplication applies baseline HDR globals once via EngineDefaultGlobalPostProcess.Apply (not every frame). Mods replace or extend those settings by calling SetGlobalPostProcess from IMod.OnLoadAsync (later loads win over earlier ones) or from a system when values must track the frame. BitmapText in TextCoordinateSpace.ScreenPixels often pairs with ViewportAnchor2D so HUD labels track ActiveCameraViewportSize (the camera's virtual canvas, not the letterboxed physical window).
World— entity creation/destruction; owns an archetype graph (entities with the same component signature share fixed-size chunks with SoA columns for cache-friendly iteration).Components<T>()/ComponentStore<T>—GetOrAdd,TryGet,Get,Remove,Containsfor entity-scoped access.QueryChunks<T>()/QueryChunks<T0, T1>()— foreach over chunks; each yields contiguousSpan<T>columns (and matchingEntityIdrows) for SIMD-friendly inner loops. Helpers such asSimdFloatoperate on those spans.ChunkQueryAllExtensions/WorldQueryExtensions—RequireSingleEntity(label)(any archetype, exactly one row),RequireSingleEntityWith<TComponent>(label), andTryGetSingleEntityWith<TComponent>(out EntityId)for singleton-style queries.EntityId— opaque id fromEntityRegistry.
Components are struct types; define them in your mod assembly (see Velocity in Cyberland.Demo).
SystemScheduler— one ordered list ofRegisterSerial/RegisterParallel/RegisterSingletoncalls.RunFramewalks entries in registration order within each phase: Early (realdeltaSeconds), Fixed (constantFixedDeltaSeconds, accumulator + substeps), Late (realdeltaSecondsagain). Serial and parallel entries runISystem.OnStart/IParallelSystem.OnStartat most once per registration (first frame the entry is enabled), withWorldand a matching chunk query passed only inOnStart.ISingletonSystementries resolve exactly one entity fromQuerySpecat startup (non-empty spec), then callOnSingletonStart(in SingletonEntity)once andISingletonEarlyUpdate/ISingletonFixedUpdate/ISingletonLateUpdatewith aSingletonEntityhandle (Get<T>()/TryGet<T>on that row) instead ofChunkQueryAll. Chunk-based systems take the per-phase chunk iterator and timing, notWorldin the phase signature—cacheWorldinOnStartwhen needed. OptionalAfterEarlyUpdate,AfterFixedUpdate,AfterLateUpdatefire once per frame after each phase. Disabled entries are skipped entirely untilSetEnabled(logicalId, true); re-enabling does not runOnStart/OnSingletonStartagain. Replacing a logical id resets lifecycle so the new instance gets start once.SetEnabled,SystemStarted,SystemEnabled,SystemDisabled, andSystemUnregistered(fromTryUnregister) are the hooks for introspection and debugging.ParallelismSettings.MaxConcurrency—0means use all logical processors.
Within each phase, order is still global registration order: each entry is ISystem (serial), IParallelSystem (parallel), or ISingletonSystem (serial, single-row), in the order registered. The host registers engine systems first, mods append during LoadAll, then the host appends render submit systems—so a mod’s systems run between pre-mod and post-mod blocks according to OnLoadAsync registration order, while Early vs Fixed vs Late is determined by which interfaces each system implements.
-
IRenderer(implemented byVulkanRenderer) — mod-facing API:SubmitSprite,SubmitTextGlyph/SubmitTextGlyphs(dedicated text path),SubmitPointLight,SubmitSpotLight,SubmitDirectionalLight,SubmitAmbientLight,SubmitPostProcessVolume,SetGlobalPostProcess,SubmitCamera,RegisterTextureRgba,RegisterTextureRgbaLinear(MSDF atlas pages, linear UNORM sampling),TryUploadTextureRgbaSubregion,RequestClose, plusSwapchainPixelSize(physical window) andActiveCameraViewportSize(camera virtual canvas, used for HUD layout). CPU-side submit queues are thread-safe forIParallelSystemworkers; GPU command recording andDrawFramestay on the render thread. -
Camera (
Camera2D) — mods place a camera entity withTransform+Camera2Dto define the player's view into the world. The camera'sViewportSizeWorldis a fixed virtual canvas in world pixels; non-matching window sizes letterbox / pillarbox instead of showing more or less world. The highest-Priorityenabled camera renders each frame; ties break by submit order. With no camera entity the renderer falls back to a default centered on the swapchain. Post-process volumes now apply when the active camera's world position is inside the volume's oriented box (not based on what's visible), so pools of effect are tied to where the camera stands. -
HDR frame pipeline (scene-linear offscreen targets, tonemap in composite): emissive prepass (optional per-texel
EmissiveTextureId) → G-buffer (opaque sprites only) → HDR: fullscreen base lighting (ambient / directional / spot) + all submitted point lights (instanced draw, SSBO) + emissive bleed → weighted blended OIT for transparent sprites → resolve to HDR → bloom → composite to the swapchain. Sort order for sprites is layer → sort key → depth hint; straight alpha where applicable. See.cursor/rules/cyberland-design-goals.mdcfor linear-color and modularity goals. -
Opaque vs transparent: set
SpriteDrawRequest.Transparent/Sprite.Transparentfor glass-style draws (WBOIT over opaque HDR); otherwise the sprite goes through the deferred G-buffer path. -
Sprite instancing (profiling): world and swapchain-overlay sprites are encoded as instanced batch runs (shared contiguous keys for albedo / normal or emissive / clip). Cast
IRenderertoVulkanRendererin host or debug builds to readLastFrameOverlaySpriteDrawCalls,LastFrameDeferredOpaqueSpriteDrawCalls,LastFrameDeferredEmissiveSpriteDrawCalls,LastFrameDeferredTransparentSpriteDrawCalls, and matching*Instances/*BatchCountcounters after a presented frame. -
CPU frame profiler (hierarchical scopes, Debug only): pass
--profile-dump=pathwith a Debug engine build to write per-scope CPU aggregates on exit (after warmup).--profile-dumpis ignored in Release (stderr explains); scope capture,FrameProfilerStats, andFrameProfilerScopeare not compiled into Release.--profile-seconds=Nstill forces uncapped pacing and a timed run in Release for lightweight checks; pair it with--perf-dump=pathfor FPS and startup or glyph counters (no hierarchical scopes). In Debug builds, F10 (cyberland.engine/profile-hud) can mirror top scopes to the window title; mods can append the same text viaFrameProfilerOverlay.AppendHud(no-op overlay text in Release). For a demo mod without leavingdisabled: truein git, use.\scripts\Profile-CyberlandDemo.ps1 -Demo idlegold(same manifest toggle pattern asRun-CyberlandDemo-Test.ps1). The script now also writes--perf-dumpwith startup milestones (startupLoadCallbackMs,startupFirstPresentMs) plus glyph cache counters (glyphCacheMisses,glyphBakedImports). For a one-command guardrail run, use.\scripts\Run-IdleGoldPerfSmoke.ps1(profiles, checks frame scopes, FPS, startup thresholds, and baked atlas effectiveness). To compare incremental HUD layout vs full measure every frame, setCYBERLAND_USE_INCREMENTAL_UI=0(orfalse);1/truere-enablesUiLayoutGating.UseIncrementalDocumentFrames(seeUiLayoutGating.ApplyEnvironmentDefaultson startup). -
Coordinate spaces — three layered frames with explicit helpers:
- World (+Y up, gameplay math): sprites with
SpriteCoordinateSpace.World(default), all*Worldlight / volume positions,Camera2Dworld position. - Camera virtual viewport (+Y down, extent
IRenderer.ActiveCameraViewportSize): HUD / UI math.SpriteCoordinateSpace.Viewportsprites,BitmapTextwithCoordinateSpace.ScreenSpace, andViewportAnchor2Dall layout against this canvas so the HUD stays locked across window resizes (letterbox / pillarbox bars don't clip UI). - Swapchain (physical window,
IRenderer.SwapchainPixelSize): the renderer uniformly scales the virtual viewport into this surface. CameraProjectionconverts world ↔ viewport ↔ swapchain;WorldScreenSpaceis a Y-flip helper within a single canvas (still useful for gameplay math that mixes +Y up and +Y down). Keep gameplay in world space rather than duplicating conversions.
- World (+Y up, gameplay math): sprites with
-
Text (
Rendering/Text/) — CPU glyph rasterization (SixLabors) into packed atlas pages (2048²) now produces MSDF-style glyph tiles and submits them through the dedicated text queue asTextGlyphDrawRequestentries (not per-glyph sprite draws). The first use of a page callsIRenderer.RegisterTextureRgbaLinearfor the full page so the GPU samples distance data as linear UNORM (no sRGB curve); additional glyphs on that page useTryUploadTextureRgbaSubregion.VulkanRendererrecords text in the swapchain UI overlay pass with instanced draws, batching by atlas texture and clip state to keep glyph draw-call counts low on large HUD screens.FontLibrarymaps logical family ids to one or more TTF/OTF byte sources;RegisterFamilyFromBytesis the mod path afterMountDefaultContent()and loading bytes fromAssetManager(e.g.Content/Fonts/...).BuiltinFonts.AddTo(library)registers embedded faces under stable idscyberland.engine/ui(sans) andcyberland.engine/mono(monospace) so samples work without shipping fonts.TextGlyphCachememoizes rasterized glyphs; GPU memory grows lazily as characters are drawn (no engine-wide preload of full font glyph sets). Atlas textures stay resident for the session unless a future explicit release API or hard resource limits apply—TextGlyphCache.Clearclears CPU maps only. -
Baked MSDF atlases (engine + mods): engine defaults ship pre-baked manifests/pages in
src/Cyberland.Engine/Rendering/Text/Baked/and load at startup before runtime rasterization fallback.GameApplicationloads up toCYBERLAND_BAKED_ATLAS_PAGE_BUDGETpages per atlas (default 4, enough for the largest shipped builtin page count); set0to skip baked loads. Regenerate engine baked assets with.\scripts\Generate-BakedMsdfAtlases.ps1. For mod custom fonts, mount content + register the font family, then callcontext.LoadBakedMsdfAtlas("Fonts/<family>/atlas/<name>.manifest.json"); any missing glyphs still fall back to runtime MSDF generation.
IInputService/SilkInputService— frame-stable input abstraction over Silk devices. The host callsBeginFrame()once per render tick before ECS updates; systems then read stable action, axis, and mouse state for that frame.FrameGameplayCommandslists logical press/release edges for that tick (stable across early/fixed/late);InputGameplayCommandExtensions(HasActionPressedThisFrame, etc.) scan them.ConsumePressed/ConsumeReleased/ConsumeAxisDeltastill buffer across frames when fixed update may skip ticks.InputBindings— runtime-editable map from action/axis ids to one or moreInputBindingentries, loaded frominput-bindings.jsonunder the app base directory.InputControl— persisted physical control token (keyboard:*,mouse:*,mouseAxis:*) used by binding JSON and runtime rebind APIs.
VirtualFileSystem— ordered mount points; later mounts override earlier (mod content over base).BlockPathhides a relative path globally (even if an earlier mount had the file).AssetManager— asyncLoadBytesAsync,LoadTextAsync,LoadJsonAsync, streamingOpenReadOrThrow.
ILocalizedContent/LocalizedContent— single façade for strings and localized media paths. CallMergeStringTableAsync("snake.json")(table file name only; IO uses the same layered VFS asModLoadContext.VirtualFileSystem) so the engine loadsContent/Locale/en/…, then parent cultures, then the active language (later merges override keys). UseTryResolveLocalizedPath,TryLoadLocalizedBytesAsync,TryLoadLocalizedTexture(synchronous, on the render thread forIRendererregistration),TryLoadLocalizedTextureAsync, orTryOpenLocalizedReadfor player-facing textures/audio/video instead of hardcodingLocale/en/….LocalizationManager— merged key → string table behindILocalizedContent.Strings;TryRemoveKey/RemoveKeydrop keys for later mods.- Active language — read from
language.jsonnext to the executable ({ "primaryCulture": "de" }). Override per launch with--lang=deor--lang de(takes precedence). Changing language requires a restart; the host mergesstrings.jsonafter all mods load. - Language-pack mods — ship
Content/Locale/{culture}/…with a higherloadOrderthan the content you override. OmitentryAssemblyinmanifest.jsonfor a content-only pack (no DLL); staging copiesmanifest.json+Content/only. TextRenderer.DrawLocalizedresolves keys at draw time; pair with merged JSON tables for HUD copy.
OpenALAudioDevice— optional; host continues without audio if OpenAL is missing.
IMod—OnLoadAsync(ModLoadContext),OnUnload().ModManifest— id, version,entryAssembly,contentRoot,loadOrder, optionaldisabled, optionalcontentBlocklist(seemanifest.json).ModLoader— discoversMods/*/manifest.json, skips mods withdisabled:trueor ids listed in the optional CLI exclude set, mounts remaining content (then applies each mod’s blocklist), loadsentryAssemblyin the default assembly load context with aResolvinghook so satellite.dllfiles resolve from the mod folder (and optionallib/), finds one concreteIMod, awaitsOnLoadAsyncto completion.
GameHostServices—Renderer(IRenderer?; concrete typeVulkanRenderer),Input(IInputService?),LocalizedContent(ILocalizedContent?), built-inFonts(FontLibrary) andTextGlyphCacheforTextRenderSystem/TextRenderer, optionalTilemaps(ITilemapDataStore?) andParticles(ParticleStore?) for tile indices and CPU particle buffers used by engine render/sim systems.LastPresentDeltaSecondsis wall time between draws (matches ECSdeltaSecondsin the stock host, which runsRunFrameonce per Render tick). Populated byGameApplicationafter the window and device exist, then passed intoModLoadContextso mods do not use static globals.
EngineDiagnostics— structured reporting withEngineErrorSeverity: Fatal (useReportFatal, dialog then process exit), Major / Minor / Warning (useReport). UntilGameApplicationenables native notifications after successful Vulkan init, delivery defaults to stderr; afterward Major uses an error-style dialog, Minor uses a warning-style dialog with session deduplication per identical title+message, Warning stays on stderr (plus debug output in the native configuration). Calls are serialized so parallel ECS workers do not interleave Win32 dialogs.EngineUnhandledExceptionBootstrap.Install()— invoked fromProgram.csbeforeGameApplicationruns: unhandled exceptions show a dialog and exit; unobserved task exceptions are logged as Warning and marked observed.UnhandledExceptionFormatter— builds readable text for crash dialogs from arbitrary exception payloads.
- Components —
Transform(local/world 2D homogeneous matrices + optional parent, with lazily decomposed position/rotation/scale properties),Sprite(includesTransparentfor WBOIT vs deferred opaque path; optionalEmissiveTextureId),SpriteLocalizedAsset(localized texture path binding for sprites),BitmapText(HUD strings; pair withTransformfor baseline),Tilemap,SpriteAnimation,ParticleEmitter,CameraFollow2D(fixed-step camera target follow). - Stores —
TilemapDataStore/ITilemapDataStore,ParticleStore(indexed by entity; not stored inside ECS chunks). - Systems (registered by the host; see frame order above) —
TransformHierarchySystem,SpriteAnimationSystem,ParticleSimulationSystem,TilemapRenderSystem,SpriteRenderSystem,ParticleRenderSystem,TextRenderSystem. Baseline global post lives inEngineDefaultGlobalPostProcess(applied at init, not as a system).
Mods typically attach Sprite + Transform and let SpriteRenderSystem submit draws; for bitmap UI copy, add BitmapText + Transform and let TextRenderSystem call TextRenderer using host Fonts / TextGlyphCache, instead of custom HUD render systems.
Mods/
Cyberland.Game/ # loadOrder 0 — locale, future core assets
Cyberland.Demo/ # loadOrder 10 — 2D sample (disabled by default in manifest)
Cyberland.Demo.Pong/ # arcade demos (disabled by default)
Cyberland.Demo.Snake/
Cyberland.Demo.BrickBreaker/
manifest.json
*.dll
Content/ # mounted to VFS (last mod wins for same path)
Shipped demo mods (Cyberland.Demo, Pong, Snake, BrickBreaker) have "disabled": true in their mods/<...>/manifest.json so a normal dotnet run loads only cyberland.base.
-
Turn on one demo — In the repo, open that mod’s
manifest.jsonand set"disabled": false(or remove thedisabledproperty). Rebuild so staging refreshesartifacts/.../Mods/from source (each build replaces that folder, so disabling a demo you had enabled removes it from the output). Alternatively, editmanifest.jsonnext to the host exe underMods/<ModName>/if you are iterating without rebuilding (the next build overwrites frommods/in the repo). -
Skip the base game — The base mod’s id is
cyberland.base. Pass--exclude-modsso only your demo runs:dotnet run --project src/Cyberland.Host/Cyberland.Host.csproj -c Debug -- --exclude-mods cyberland.base
Add other ids to the comma-separated list if you temporarily enable multiple mods and want to exclude some of them.
-
Sync assets — Demo
Content/may still need.\scripts\Sync-CyberlandAssets.ps1(see Asset setup).
Example (see mods/Cyberland.Game/manifest.json):
id— stable string id.entryAssembly— DLL name containing anIModimplementation.contentRoot— relative folder mounted for this mod (oftenContent).loadOrder— lower runs earlier (manifests sorted by load order, then id).disabled(optional) — whentrue, the loader ignores the mod: no content mount, no blocklist, no assembly load (defaultfalse).contentBlocklist(optional) — array of virtual relative paths to hide after this mod’s content is mounted (blocks win over all mounts; use normal file overrides when you want to replace content instead).
- Ship a public non-abstract class implementing
IMod(the loader picks the first exported type assignable toIMod). manifest.jsonis the source of truth for id, name, version,entryAssembly,contentRoot,loadOrder, etc.;ModLoadContext.ManifestinOnLoadAsyncis that deserialized data (do not duplicate it in theIModtype).OnLoadAsync: register systems (with stable logical ids), spawn entities (preferSceneSetup.SetupSceneAsyncawaited beforeRegister*), merge localization, callcontext.MountDefaultContent()if you rely onContent/under the mod folder.
Every ECS system is registered with a non-empty logical id (convention: "<modId>/<purpose>", e.g. cyberland.demo/sprite-move). Mods load in loadOrder order; a later mod can:
- Extend — register new ids.
- Replace — call
RegisterSerial/RegisterParallel/RegisterSingletonagain with an id already used; the implementation is swapped in place (frame order among other systems stays the same). - Remove —
TryUnregister(logicalId)drops that system from the scheduler list. - Toggle without removing —
context.SetSystemEnabled(logicalId, …)(orcontext.Scheduler.SetEnabled) skips per-frame work while keeping registration order andOnStartsemantics (no secondOnStartafter re-enable).
Use context.RegisterSerial, context.RegisterParallel, context.RegisterSingleton, context.SetSystemEnabled, and context.TryUnregister (wrappers around SystemScheduler). Do not reuse the same id across serial vs parallel registration (singleton uses the serial scheduling path; still pick a distinct id if a parallel system occupied it).
- Override a file — ship a file at the same virtual path from a later mod (VFS last mount wins).
- Hide a path —
context.HideContentPath("relative/path")or declarecontentBlocklistinmanifest.jsonso the path does not resolve. - Remove a localization key —
context.TryRemoveLocalizationKey("key")after earlier mods merged strings.
| Member | Use |
|---|---|
Input |
Action/axis queries (IsDown, WasPressed, ReadAxis), FrameGameplayCommands / extension helpers for same-frame edges, mouse position/delta, runtime rebinds via Input.Bindings. |
Renderer |
IRenderer: SwapchainPixelSize / ActiveCameraViewportSize, SubmitSprite, SubmitPointLight / SubmitSpotLight / SubmitDirectionalLight / SubmitAmbientLight, SubmitPostProcessVolume, SetGlobalPostProcess, SubmitCamera, RegisterTextureRgba, RegisterTextureRgbaLinear, TryUploadTextureRgbaSubregion, RequestClose (e.g. Cyberland.Demo). |
Tilemaps |
Optional; holds per-entity tile index buffers for TilemapRenderSystem. |
Particles |
Optional; CPU particle buckets for ParticleSimulationSystem / ParticleRenderSystem. |
The host sets Renderer and Input only after successful window/input setup; systems should null-check when relevant.
Add logic under mods/<YourMod>/ with a manifest.json and a .csproj referencing Cyberland.Engine. Add the mod project to Cyberland.sln; scripts/StageModsForHost.ps1 picks up each mods/*/ folder that contains manifest.json (skipped when disabled is true).
namespace MyMod;
public struct MyComponent
{
public float Value;
}Use world.Components<MyComponent>().GetOrAdd(entity) (or TryGet) to associate state with entities.
Put one-off ECS spawn (camera, session/control tags, playfield, HUD BitmapText rows, lights, GlobalPostProcessSource) in a static helper beside Mod.cs—usually SceneSetup with public static async ValueTask SetupSceneAsync(ModLoadContext context, CancellationToken cancellationToken = default). Await it from IMod.OnLoadAsync before RegisterSerial / RegisterParallel / RegisterSingleton, so startup ordering is deterministic and the scheduler only lists runtime systems.
The method stays async so you can later await scene JSON, ILocalizedContent.MergeStringTableAsync, or other I/O without restructuring OnLoadAsync. Respect cancellationToken when you add long-running loads.
Reference: mods/Cyberland.Demo/SceneSetup.cs, mods/Cyberland.Demo.BrickBreaker/SceneSetup.cs, mods/Cyberland.Demo.Pong/SceneSetup.cs, mods/Cyberland.Demo.Snake/SceneSetup.cs, mods/Cyberland.Demo.MouseChase/SceneSetup.cs, and matching Mod.cs files—same SetupSceneAsync + OnLoadAsync pattern.
ISystem— single-threaded chunk iteration; use for anything that must stay on one thread (some input flows, strict ordering withGameHostServices).IParallelSystem— use for CPU-heavy work overQueryChunks<T>()(per-chunk spans are safe to split acrossParallel.For/Parallel.ForEach); seeVelocityDampSysteminCyberland.Demo.ISingletonSystem— exactly one entity must matchQuerySpec; phase hooks takeSingletonEntity(Get<T>()on that row) instead ofChunkQueryAll. Use for session rows, HUD singletons, or any true single-row driver—seeIntegrateSystem/FpsDisplaySysteminCyberland.Demo.
context.RegisterSerial("my.mod/main", new MySystem(context.Host));
context.RegisterParallel("my.mod/batch", new MyParallelSystem());
context.RegisterSingleton("my.mod/session", new MySingletonSystem());First-time registration order is the run order for the scheduler’s single list. Replacing an existing logical id keeps that system’s position in the list.
var id = context.World.CreateEntity();
ref var c = ref context.World.Components<MyComponent>().GetOrAdd(id);
c = new MyComponent { Value = 1f };- Read actions and axes through
context.Host.Input(IsDown,WasPressed,ReadAxis,HasActionPressedThisFrame,ConsumePressed, …) and update bindings throughcontext.Host.Input.Bindingswhen supporting runtime rebind UI. - Lighting — queue
SubmitPointLight,SubmitSpotLight,SubmitDirectionalLight, andSubmitAmbientLightoncontext.Host.Renderereach frame you need them (sameIRendereras sprites). The deferred path accumulates all submitted ambients, all directionals and spots in the base fullscreen pass (up to engine caps), and all point lights in the instanced pass. - For drawing, prefer
Sprite+Transformon entities; the engine’sSpriteRenderSystemsubmitsSpriteDrawRequestin world space after your mod systems run. For HUD text, preferBitmapText+TransformandTextRenderSystem. For one-off or procedural draws, buildSpriteDrawRequestyourself and callcontext.Host.Renderer?.SubmitSprite(...)(and post volumes as needed).
- Resolve paths against the
VirtualFileSystem(mounts include modContent/roots in load order). - Use
AssetManagerwith the same VFS instance the host constructed (passed through localization/bootstrap as inGameApplication).
- Add a project under
mods/YourMod/referencingCyberland.Engine. - Implement
IMod. - Add
manifest.json. - Ensure the mod appears under
mods/YourMod/withmanifest.json— no host project reference is required. Rebuild Cyberland.Host soMods/YourMod/is populated in the output directory.
| Example | Location | Shows |
|---|---|---|
| Base mod entry | mods/Cyberland.Game/BaseGameMod.cs |
Minimal IMod, locale Content/ |
| Demo mod entry | mods/Cyberland.Demo/Mod.cs |
IMod.OnLoadAsync, locale MergeStringTable, ModLoadContext.AddDefaultInputBinding, RegisterSerial / RegisterParallel / RegisterSingleton (e.g. cyberland.demo/integrate, cyberland.demo/velocity-damp) |
| BrickBreaker scene | mods/Cyberland.Demo.BrickBreaker/SceneSetup.cs |
SetupSceneAsync — cold-start convention (await before Register*); see Mod.cs |
| Input + sim | mods/Cyberland.Demo/Systems/InputSystem.cs, IntegrateSystem, SceneSetup |
IParallelSystem (InputSystem), ISingletonSystem (IntegrateSystem), scene tags; HDR cold start in SceneSetup and GameApplication baseline |
| Parallel ECS | mods/Cyberland.Demo/Systems/VelocityDampSystem.cs |
IParallelSystem, QueryChunks<Velocity>, SimdFloat on packed floats |
| Host bootstrap | src/Cyberland.Engine/GameApplication.cs |
Lifecycle, LoadAll, optional --exclude-mods |
Demo mods are off in manifest.json by default; see Enabling a demo mod for testing. To run only the base game with no samples, you do not need --exclude-mods (demos are already disabled). To load several mods and drop specific ones, use e.g. --exclude-mods cyberland.demo,cyberland.demo.pong.
Game rules and session state live in mod code (e.g. paddle/ball logic, brick grid, snake movement). Cyberland.Demo, Pong, Snake, BrickBreaker, and MouseChase drive Transform / Sprite from simulation or layout systems and each create a Camera2D entity during cold start (typically SceneSetup.SetupSceneAsync, awaited from IMod.OnLoadAsync) using a fixed 1280×720 virtual canvas so gameplay stays the same size regardless of window resolution. All shipped demo mods follow SetupSceneAsync before Register*. The host applies a baseline once via EngineDefaultGlobalPostProcess, and demo SceneSetup helpers add a GlobalPostProcessSource entity where samples tune emissive and bloom. Cyberland.Demo updates a fullscreen PostProcessVolumeSource each late tick (cyberland.demo/hdr-post-volume) so bloom can track the player. Each demo mod registers its default key bindings in OnLoadAsync via ModLoadContext.AddDefaultInputBinding. Use ModLayoutViewport.VirtualSizeForSimulation / VirtualSizeForPresentation when you need a consistent virtual canvas read across phases; see in-mod READMEs under mods/Cyberland.Demo*.
Cyberland.Demo.Snake drives Sprite and BitmapText entities from a visual sync system (grid-aligned quads); the playfield background uses the engine tilemap path via host.Tilemaps. Samples use GameHostServices.Fonts / TextGlyphCache only when calling TextRenderer directly for special cases.
input-bindings.json— lives next to the host executable. First run creates the file withInputBindings.LoadDefaults, then enabled mods’IMod.OnLoadAsyncmay add more default actions viaModLoadContext.AddDefaultInputBinding. A user file, when present, replaces the in-memory table at startup before mods run.
| Issue | Suggestion |
|---|---|
| Vulkan / GPU errors on startup | Update GPU drivers; ensure Vulkan is supported. The engine surfaces a message via UserMessageDialog / GraphicsInitializationException. |
| Unexpected crash or “Unhandled exception” dialog | The host registers EngineUnhandledExceptionBootstrap at startup; read the technical detail in the dialog and check recent mod or engine changes. |
| Mod not loading | Check Mods/<Id>/manifest.json, entryAssembly name, and that the DLL is staged next to manifest.json. |
| Empty or missing content | Confirm contentRoot exists and ModLoader mount order; later mods override earlier paths for the same relative path. |
src/Cyberland.Engine/Rendering/(and embeddedRendering/Shaders/*.glsl) — Vulkan 2D pipeline implementation and shaders; seeVulkanRenderer.cs(swapchain/present),VulkanRenderer.Deferred.Recording.cs(per-frame pass order), andDeferredRenderingConstants.csfor HDR/bloom topology..cursor/rules/cyberland-mod-host-architecture.mdc— host vs mod boundaries and checklists..cursor/rules/cyberland-mod-patterns-hdr.mdc— mod folder layout, scene-setup system, query vs store usage, parallel registration (seemods/Cyberland.Demo)..cursor/rules/cyberland-world-screen-space.mdc— world vs screen Y conventions..cursor/rules/cyberland-code-style.mdc— comments and readability expectations.
Add your license here if applicable.