Contributing: Architecture and Patterns

This page documents the contributor-facing architecture of Kore and the implementation patterns worth reusing.

It is intentionally focused on the parts that help you answer two questions quickly:

  1. Which module should I edit?
  2. Which project pattern should I follow instead of inventing a new one?

Project tree at a glance

Kore/
├─ bindings/
├─ build-logic/
├─ generation/
├─ helpers/
├─ kore/
├─ oop/
└─ website/
Plain text

This tree is intentionally small. It reflects the modules contributors should reason about first; local-only or git-excluded sandboxes are omitted on purpose.

How the main modules relate

  • bindings/ imports datapacks and emits Kotlin bindings for resources and registries.
  • build-logic/ centralizes shared Gradle conventions and project metadata.
  • generation/ transforms upstream Minecraft data into generated Kotlin/resources consumed by kore/.
  • helpers/ and oop/ layer higher-level APIs on top of the core DSL.
  • kore/ is the main DSL and runtime surface most contributors touch first.
  • website/ documents both the public API and contributor workflows.

Module boundaries and edit surfaces

bindings/

  • Purpose: datapack importer and Kotlin binding generator.
  • Typical flow: explorer -> normalized entities -> writer output.
  • Common edit surface: explorer.kt, entities.kt, writer.kt, then tests under bindings/src/commonTest.
  • Pattern to preserve: single-namespace packs stay compact, multi-namespace packs become namespace-nested objects, and worldgen content is grouped under Worldgen.

build-logic/

  • Purpose: shared Gradle conventions, publishing logic, and project metadata.
  • Common edit surface: convention plugins and Project.kt.
  • Edit here when changing build behavior, publication rules, or project versioning.

generation/

  • Purpose: source-data processing and generated Kotlin/resource output.
  • Edit here when a generated enum, registry wrapper, or source-derived structure is wrong.
  • Never fix a generation issue by editing kore/src/commonMain/kotlin/io/github/ayfri/kore/generated or build/generated/... directly.
  • Full walkthrough: Contributing: The Generation Pipeline.

helpers/

  • Purpose: optional higher-level helpers built on the core DSL.
  • Edit here only when the issue explicitly targets helper abstractions or reusable convenience APIs.
  • Mirror core DSL patterns instead of creating a parallel architecture.

kore/

  • Purpose: core DSL, typed arguments, command wrappers, serializers, worldgen builders, and data-driven resources.
  • Common edit surface: feature classes, DataPack registration, Function extensions, serializers, and tests under kore/src/commonTest.
  • A typical change in this module touches one feature family end to end: model, registration, builder entry point, tests, and docs.

oop/

  • Purpose: object-oriented abstractions layered on top of core Kore primitives.
  • Edit here when the issue is specifically about that façade, not when the underlying DSL itself is wrong.
  • Keep naming and behavior aligned with kore/ to avoid divergent APIs.

website/

Core patterns to reuse

Argument wrappers and literals

Kore prefers typed wrappers over raw strings.

  • Argument is the base abstraction for command-safe value types.
  • Generated argument wrappers are the preferred representation for registry references.
  • Tag variants encode #namespace:name conventions.
  • Literal helpers such as literal(), int(), and float() keep command assembly explicit and safe.

This improves autocomplete quality, reduces malformed command strings, and keeps serialization predictable.

Command wrappers

Command APIs generally live as Function extension functions.

  • Build lines with addLine(command("name", args...)).
  • Accept typed arguments directly when possible.
  • Keep wrappers thin: syntax composition belongs here, domain state belongs in arguments or data classes.

This keeps generated commands deterministic and test-friendly.

Generator pattern

Most data-driven resources in kore follow one consistent model:

  1. A serializable feature class extends Generator.
  2. The class defines its resourceFolder, transient fileName, and generateJson(dataPack) implementation.
  3. DataPack registers a typed generator list through registerGenerator<T>().
  4. A DataPack extension function instantiates and registers the feature.
  5. The extension returns a typed *Argument for later references.

Concrete references:

For the procedural version of this pattern, use Contributing: Creating a New Generator.

Serializer strategy

Prefer an existing serializer before creating a new one.

Frequently reused serializers, in alphabetical order:

Decision rule:

  1. Check whether an existing serializer already matches the target JSON shape.
  2. If not, see whether the model can be expressed with an existing pattern.
  3. Only then add a new serializer, with focused tests.

New sealed hierarchies serialized with NamespacedPolymorphicSerializer (a type-discriminated Minecraft polymorphic shape) must not enumerate subtypes by hand or via reflection. Annotate the sealed base with @GeneratedSealedSerializer; the kore-ksp module's SealedSerializerProcessor generates a reflection-free fooSealedSerializer() factory at compile time, which the family's serializer object passes to NamespacedPolymorphicSerializer's constructor. The factory returns a SealedDispatcher that switches on the subtype's simple name, so serializing one subtype only loads that subtype, not the whole family.

Subtype names

A subtype is written under the snake_case of its class name: LinearPos becomes linear_pos. When the subtypes of a family end with a word the game name leaves out, the family's serializer sets it once as suffix, and the name drops it when present:

data object IntProviderSerializer : NamespacedPolymorphicSerializer<IntProvider>(
	intProviderSealedSerializer(),
	suffix = "IntProvider",
)
Kotlin

UniformIntProvider is then written as uniform. @SerialName stays only on a subtype whose game name isn't that name, such as @SerialName("type_specific/sheep") class SheepSubPredicate, written as its exact value without the namespace. namespace is the prefix of the written names, minecraft by default, null for names written bare and fabric for the Fabric resource conditions. Two subtypes of a family written under the same name make its serializer fail.

Property names

Properties are written in snake_case in both JSON and SNBT, so maxDamage becomes max_damage with no annotation. @JsonSerialName("offsetX") overrides the key in both formats, as LocationCheck does for its camel-case offsets. A @SerialName is only needed on a sealed subtype whose name isn't its class name in snake_case (see Subtype names) or when the key isn't the snake_case of the property, and in SNBT it is snake_cased as well, so a key such as offsetX needs @JsonSerialName. Map keys and raw NbtCompound content are never renamed.

SNBT is read the same way: decodeFromSnbt and decodeFromNbtTag look the keys of a class up under their snake_case names. A serializer that reads NBT by hand goes through Decoder.nbtDecoder instead of an is NbtDecoder check, and decodes nested values with decodeFromNbtTag.

Number text

A finite Float or Double is written with Java's toString() layout on the JVM and on JS, in JSON and in SNBT: plain with at least one decimal from 0.001 to below 10000000 (1f is 1.0, 0.1 is 0.1), else a mantissa with at least one decimal and an exponent (1e-5 is 1.0E-5, 3e7 is 3.0E7, 16777216f is 1.6777216E7). Kotlin/JS prints 1f as 1 and 1e-5 as 0.00001, so it needs the rewrite, and the JVM does nothing: toCanonicalString() is toString() there.

DataPack.jsonEncoder applies it to every data pack file. On the JVM it encodes like a plain Json, on JS it wraps the encoder to write toCanonicalString() for each float and double. A serializer that builds a nested JSON tree from encoder.json calls encodeToCanonicalJsonElement instead of encodeToJsonElement, and SNBT goes through toSnbt(). A plain Json instance prints the platform text. When the shortest digits are a single one, Java prints the closest two-digit form, and so does toCanonicalString() on JS (Double.MIN_VALUE is 4.9E-324, Float.MIN_VALUE is 1.4E-45).

Per-pack state

Helpers that must set something up once per datapack (a load function, a shared objective, a runtime object) keep that bookkeeping on the pack with DataPack.state, never in a top-level map or set. The state is released with the pack, and two packs sharing a name never see each other's state.

private val initializedTimers = DataPackStateKey<MutableSet<String>>("oop.timers")

fun DataPack.registerTimer(timer: Timer) {
	if (state(initializedTimers) { mutableSetOf() }.add(timer.name)) load("timer_${timer.name}_init") { /* ... */ }
}
Kotlin

stateOrNull(key) reads the state without creating it.

Fast heuristics when you are unsure where a change belongs

  • Build, publishing, or versioning behavior -> build-logic/ and root Gradle metadata.
  • Contributor or user-facing explanation -> website/ in the same PR.
  • Datapack import output shape -> bindings/ pipeline and tests.
  • Generated enum or registry wrapper shape -> generation/, then regenerate and update kore/ consumers.
  • New registry-backed DSL resource -> usually kore/, plus generation if a new argument wrapper is required.

Testing patterns that speed up contribution

  • Feature tests usually assert emitted JSON payloads and generated command/resource lines.
  • Most specs build a dataPack { } in commonTest and never call generate(), so they run on the JVM, Node.js, and a headless browser alike (see Multiplatform Support). The testDataPack(...) helper wraps real file/ZIP/JAR generation and is JVM/Node-only - keep specs that call it in jvmTest.
  • Serializer tests should focus on roundtrip behavior and JSON or SNBT shape checks.

When you touch generators, the usual path is: model -> DataPack registration -> builder entry point -> tests -> docs.

Documentation pages

Contributor pages are easier to maintain when they link to a single source of truth instead of restating the same rules.

Required frontmatter keys, in alphabetical order:

  • date-created
  • date-modified
  • description
  • keywords
  • nav-title
  • root
  • routeOverride
  • title

Versions that change with each release are placeholders, a key between double curly braces, expanded at build time in prose, links and code blocks, the .md copies and llms-full.txt included:

Key Value
java.version Java version required by mc.version
kore.mavenSnapshot Maven snapshot coordinate of master (<Kore version>-<master Minecraft>-SNAPSHOT)
kore.release Latest stable release, e.g. 2.16.0-26.2
kore.snapshot Latest release for a Minecraft snapshot, or kore.release when there is none newer
kore.version Kore part of kore.release
mc.packFormatMajor Data pack format of mc.version, major part
mc.packFormatMinor Data pack format of mc.version, minor part
mc.version Minecraft version of kore.release

The values come from the GitHub releases and PixiGeko's versions_data.json, fetched once per build directory: rerun fetchGitHubReleases and fetchMinecraftVersions with --rerun to refresh a local copy. An unknown key, a placeholder in the frontmatter or a heading, or an export without any stable release fails the build.

An image whose destination starts with mc: renders a vanilla texture inline, the path being relative to textures/ without extension, e.g. ![Furnace](mc:block/furnace_front). Use it only where seeing the in-game block, item or GUI sprite tells the reader something (a workstation column, frame styles), never as decoration, and check the path exists in misode/mcmeta first. Animated textures show their first frame.

Bigger in-game renders (dialogs, containers, HUD elements, toasts, trades) live in components/mc/DocMockups.kt, built from the vanilla GUI textures in components/mc/McUi.kt. Embed one right after the example it renders with {{{ .components.mc.KitsMenuMockup }}}, and update it whenever that example changes. Mockups use vanilla GUI coordinates through gui(pixels) (GUI scale 2) and McAt, so offsets copied from the game's screen classes land on the same pixels. Text is drawn as SVG pixel squares by McFont.kt, from glyph bitmaps of the OFL Minecraft font, so it stays sharp at any zoom where browser font rendering blurs pixel fonts. McText takes a list of McSpan runs (color, bold, italic, underlined, strikethrough) for lines mixing styles, like chat messages built from several text components.

Interactive explainers (sliders that redraw a distribution, for example) live in components/doc/, like ProviderExplorer. Embed one the same way, right after the table it illustrates, and write the sentence introducing it so the page reads fine without it: the .md copies and llms.txt drop the widget line. The pills, toggle groups, slider and text fields these widgets share live in components/doc/DocControls.kt: reuse them instead of styling new controls.

Keep routes stable, keep navigation intentional, and update entry pages when a new doc should become discoverable.

How to write a documentation page

Content rules that keep the docs consistent and trustworthy:

  • Example-first. Every public feature needs at least one copy-pastable Kotlin snippet. Verify each snippet against the real API (the source or a test) before committing - never invent method names or argument shapes. Use tabs for indentation, matching the Kotlin code style.
  • State capabilities, not changes. Write what the API does, not what changed between versions. Avoid changelog phrasing like "now possible" or "new in this release" in body copy; that information belongs in commit messages and release notes, not in a reference page that must read correctly a year later.
  • Cross-link inline, not in a pile. Link related pages from the sentence that mentions the concept (for example, link Scoreboards the first time you mention scoreboards). Prefer one good inline link over a long trailing list. Keep any See also section short - three or four of the most relevant links, not a dump of everything related.
  • Link to the Minecraft Wiki for vanilla concepts. When a page touches a vanilla system (a command, a registry, an NBT structure, a pack format), link the relevant Minecraft Wiki page. It is high quality and saves Kore from restating vanilla behavior - Kore docs should explain the Kore DSL and defer vanilla semantics to the wiki.
  • Do not duplicate. If a concept already has a home page, link it instead of re-explaining it. One source of truth per topic; everything else points at it.

Why Kore uses these technical choices

  • Centralized serializers keep JSON and NBT output stable across modules and make diffs easier to review.
  • Generator-first data-driven features keep feature additions mechanical instead of bespoke.
  • Tests close to feature families help catch schema drift when Minecraft updates resource definitions.
  • Thin command wrappers preserve command predictability while still covering vanilla syntax broadly.
  • Type-safe arguments over raw strings reduce command regressions and improve IDE discoverability.

See also