Zum Inhalt springen

Contentplate – Turning Game Configuration Upside Down

Joshua Dean Küpper 22 Min. Lesezeit
The Contentplate value editor showing a typed value with its expression and a live preview.
Contentplate gives our game designers a typed, expression-based view of every gameplay value and serves the results to the game server in real-time.

Every game lives and dies by its numbers. The damage of a sword, the chance for a buried treasure to spawn, the cooldown of an ability, the color of a rarity, the pitch of a level-up sound – all of these are tiny values that, taken together, define how a game feels. And all of them have to be tweaked, over and over, until the balance is just right. Game design is, to a large degree, the art of nudging these numbers.

Traditionally, those values live in the worst possible place for that kind of iteration: inside the code, or scattered across a pile of YAML and JSON config files. Changing one means editing a file, committing it, building, deploying, and restarting the server – just to see whether 0.15 should really have been 0.12. Worse, the people who are best at judging that difference are usually our game designers, and they are precisely the ones who can’t touch the code. So every balancing change turns into a ping-pong match between game designers and developers.

There are other problems hiding in that pile of config files, too. There is no history, so nobody can tell when a value changed or why. There is no safe way to experiment, because staging and production drift apart and the diff is impossible to see. Values are untyped strings, so a color, a duration, and a probability all look the same in the file and are equally easy to get wrong. And there is no feedback: you set a particle’s offset to 1.5 and have no idea what that looks like until you load it in-game.

We had already solved a related problem for our builders with ConfigPositions, by putting configuration into the world where they could see and edit it. Contentplate is the same idea, aimed at our game designers: take the values out of the code and the config files, give them a typed, expression-based home with live previews and a Git-like workflow, and push the results to the game server in real-time. It quite literally turns our game configuration upside down – the values now live above the code, not buried inside it.

An ice cave with floating texts on the ground and in the air that contain configuration values.

ConfigPositions – A Fusion of Configuration and Level Data

Managing configuration data, that corresponds to different locations in a level, has always been a hassle in Minecraft. Level and configuration data can get out of sync or the configuration data was set incorrectly from the beginning. Read more about how ConfigPositions make that easier.

Weiterlesen →

What Is Contentplate?

Contentplate is a content management system for game content. Where a classic CMS stores articles and images, Contentplate stores typed, expression-based values that are served to our Minecraft game servers at runtime. The values are kept in MongoDB, cached in memory on the consuming server, and updated in real-time. The system itself is a small Ktor/Kotlin backend with a Nuxt web app for our designers, and the game servers consume it through a dedicated Kotlin client library.

The fundamental unit is a value. Each value has a unique key, a type, and a payload. The key is an underscore-delimited string that mirrors a folder structure, so related values cluster together and stay browsable:

{
	"key": "dungeon_hero_damage",
	"type": "NUMBER",
	"value": "baseDamage * 1.5 + bonusDamage"
}

That is one value: a number called dungeon_hero_damage whose payload is not a fixed number at all, but an expression. This single idea – that a value is typed and can be an expression rather than a constant – is what everything else in Contentplate builds on.

The Contentplate value browser with a folder tree built from underscore-delimited keys.
Keys mirror a folder structure, so related values cluster together: dungeon_hero_damage lives next to dungeon_hero_health, and the whole tree stays browsable.

Specialized, Typed Values

In a config file, every value is ultimately a string. A color is a string, a sound is a string, a probability is a string – and so it is on the editor to remember which is which and to format each one correctly. Contentplate flips that around: every value has a real type, and the editor gives each type an interface built for it.

It is important to stress that Contentplate is not just a fancy spreadsheet of numbers and formulas. Yes, the scalar types – numbers, booleans, strings and colors – are edited as what they are, with sounds offering the sound catalogue and a play button, and colors showing a swatch. But the system goes far beyond that, all the way up to rich, structured game-content objects. There are over twenty types in total, including biomes, sounds, colors, particle effects, items, monsters, dungeon environments, dungeon classes, dungeon cards, spatial transformations, NPC appearances, journey recipes and traders. Each of these gets a structured editor with exactly the fields it needs, instead of a freeform blob of text.

The point isn’t the exact catalogue of types; it’s that the representation matches the meaning. A game designer editing the pitch of a sound, the rarity color of an item, the biome of a dungeon environment or the card layout of a whole dungeon class is working with a control that understands what that value is, which makes the whole thing intuitive instead of error-prone.

A specialized value editor in Contentplate showing type-specific controls instead of a text field.
Each type gets a purpose-built editor: a color shows a swatch, a sound offers the catalogue and a play button, and structured types like particles or NPCs expose exactly their fields.

Formulas Instead of Constants

The single most important difference to a config file is that a value can be a formula. Number values are evaluated with exp4j (plus a set of custom math functions), booleans with jbool_expressions, and strings through variable interpolation. The expression is stored as-is and evaluated lazily, at the moment the game server reads it.

This means a designer can write things like:

clamp(level * 2, 0, 100)

instead of having to pre-compute a table of values or bake the formula into the code. The full toolbox – arithmetic, min/max/clamp/lerp/round, trigonometry, random functions, constants like pi – is available, but the important part is the shift in mindset: a designer no longer edits a number, they edit the rule that produces the number. Tuning a curve becomes a one-line change in Contentplate rather than a code change.

Variables

Formulas would be far less powerful if they could only refer to constants. That is why values can use variables – runtime placeholders that the game server injects at the moment of evaluation. The same stored value can then produce different results depending on context: the player’s stats, the current game state, the difficulty of a mission, and so on.

The expression health * 0.1 + bonus, for example, is meaningless on its own. When the game server reads it, it supplies the current health and bonus, and Contentplate evaluates the formula against that context. A single value definition thus covers every player and every situation, instead of needing a separate entry for each. Number-typed values take numeric variables, booleans take boolean variables, and string values interpolate whatever is passed in – but in every case, the variables are the bridge between a static definition and a dynamic, per-call result.

Constants You Can Change at Runtime

So how does this look from the game server’s side? This is the part that we’re really proud of. The client library exposes each value type through a storeNumberStore, StringStore, ColorStore, and so on – and there are two ways to read a value.

The first reads like an ordinary constant. Using a Kotlin property delegate, you bind a field to a key once and then use it like any other val:

private val attackCooldownThreshold by NumberStore("dungeon_player_base_sweepAttack_attackCooldownThreshold")

The trick is that this looks like a constant but behaves like a live feed: the delegate re-queries the store on every single access, rather than capturing the value once. (How that store stays current is covered under “Live Updates” below.)

The second form is an indexed read, for one-off lookups or when you need to pass variables in for the evaluation:

NumberStore[
    "dungeon_global_gui_equipmentShop_offerGenerator_score",
    mapOf(
        "environment" to highestEnvironment.index,
        "highestScore" to highestScore,
    ),
].toInt()

Here the variables (environment, highestScore) become the context for the formula, the expression is evaluated, and we get a concrete result back – which we can then convert to whatever native type we need. Both forms ultimately go through the same path: resolve any references, evaluate the expression, return the value.

For developers, this is a small surface: declaring a tunable game value is one line of code, and from then on a designer owns it.

Reusable References and Semantics

Values rarely live in isolation. A base damage multiplier feeds a dozen damage formulas; a single accent color is used by many UI elements. Duplicating those numbers everywhere is exactly the maintenance trap config files fall into. Contentplate avoids it with nested references: any expression can embed {anotherKey}, and that token is replaced by the evaluated result of the referenced value before the expression is computed.

health * {dungeon_base_multiplier} + {dungeon_bonus_health}

References resolve recursively (with cycle detection and a depth limit, so a broken chain fails loudly instead of looping forever). Values that exist purely to be referenced this way can be marked as helpers – reusable constants and sub-expressions that the game server never reads directly, but that other values build upon.

What makes this more than just deduplication is that it lets designers configure by semantic role rather than by concrete value. Instead of pasting -65536 into every place that should be “danger red”, you define a value for the meaning – the danger color, the base multiplier, the global cooldown – and reference it. Change the meaning in one place and everything that depends on it follows. The configuration starts to describe intent instead of magic numbers.

Live Updates

All of this would be far less useful if changes only took effect on the next restart. They don’t. Contentplate’s client library subscribes to MongoDB change streams, so whenever a value is written in the web app, the database pushes that change to every connected game server, which updates its in-memory cache immediately.

The result is that the stores always serve the newest value, and a designer’s edit is reflected in-game within moments – no restart, no redeploy, no waiting. Combined with the always-live reads described above, this closes the iteration loop: a designer changes a number in the editor and watches the game react. That tight feedback is what the system was built for.

A diagram of the live-update flow from the editor through MongoDB change streams to the game server cache.
A value written in the editor travels through a MongoDB change stream to every connected game server, which updates its in-memory cache immediately — no restart required.

It is one thing to describe that loop and another to watch it happen. In the clip below we have Contentplate and Minecraft open side by side: editing a single value in the editor changes what the running game shows, within moments and without touching the server.

Branches and Diffs

Editing live values that real players are experiencing is, of course, a little terrifying. So Contentplate borrows the safety net from a tool every developer already trusts: Git. Values exist in separate environments – a staging branch and a production branch – and our designers work against staging, where they can experiment freely without affecting anyone.

When they are happy, they can compare the two and review exactly what would change before promoting anything to production. It is the familiar branch-and-diff workflow, applied to game configuration: experiment in isolation, see the diff, then apply. The fear of “did I just break the live game with a typo” is replaced by a reviewable, deliberate promotion step.

The Contentplate branch switcher next to a staging-versus-production diff of changed values.
Designers work against staging and can review the exact diff to production before promoting — the familiar branch-and-diff workflow, applied to game configuration.

Built for Collaboration

Because Contentplate is a real application rather than a folder of files, it can carry all the context that a config file throws away. Values can hold internal notes and human-readable descriptions, so the reasoning behind a tricky formula travels with it instead of living in someone’s memory. The whole value tree is browsable and searchable, which matters a lot when there are thousands of entries.

The system also tracks editorial state for you. It knows when a value is still pristine – freshly created and never touched, a good signal that it still holds a placeholder and needs attention. It flags values as faulty when the game server fails to evaluate them, so a broken expression or a missing reference surfaces in the UI instead of silently misbehaving in-game. And it records when each value was last actually read, which helps to spot the ones nobody uses anymore. None of this is something an editor has to maintain by hand – it is simply there, making a large, shared configuration manageable.

Permissions and the Separation of Development and Game Design

This is the heart of “turning configuration upside down”. With config files, the structure and the values are the same artifact, owned by whoever can edit the code. Contentplate splits those two responsibilities cleanly. Developers define the types and the keys – the shape of the configuration and where each value plugs into the game. Game designers own the values – the actual numbers, formulas and colors.

Access is governed by proper role-based permissions: writing to production requires a dedicated role and is gated behind a protected mode, and every mutation is recorded in an audit log, so there is always a trail of who changed what. Within those guardrails, designers are free to work in parallel with developers instead of waiting on them. The redeploy ping-pong is gone: a balancing pass no longer needs an engineer, and an engineer shipping a feature no longer blocks on final numbers. Each side owns the part of the problem they are actually best at.

Previewing Content

A number you can’t see the effect of is still hard to tune, so the editor leans heavily on previews. It renders real Minecraft assets directly from our asset provider at mcassets.justchunks.net – item, monster and card textures, the sound catalogue, and so on – so that a value is shown as it will actually appear in the game. Sounds can be played, colors and icons are rendered, and player-based looks are drawn through our Charon service, which turns a profile into a head or full skin.

The effect is that a designer styling an NPC, picking a rarity color, or choosing a sound sees the genuine result while they edit, not an abstract string they have to imagine. It closes the same feedback loop as live updates, but for appearance rather than behavior.

An in-editor preview rendering a real Minecraft item icon and an NPC skin next to the value being edited.
The editor renders real Minecraft assets — item, monster and card textures from our asset provider, and player skins through Charon — so a value is shown as it will actually appear in-game.

The AI Assistant

The newest – and, to be clear, by far the least battle-tested – piece of the system is an assistant that understands the value model. Where everything above has been carrying live games for a while, this part is recent, and we are still figuring out where it genuinely helps and where it just gets in the way. We are including it here because it follows naturally from the architecture, not because we think it is finished.

What makes it fit rather than bolt on is that it is a thin layer over the same value API everything else uses. It is built on Koog, JetBrains’ Kotlin agentic framework, which sits comfortably in our Kotlin-first stack, and it talks to Contentplate through a small set of tools that wrap the exact same API the web app and client library already call. The agent has no privileged backdoor into the data; it searches the tree, explains what a value does, and drafts new ones through the same endpoints a designer’s browser does. It also leans on the metadata the system already carries – the notes, descriptions and naming conventions – so when someone asks about “Knold”, it knows to look under the monsterGold keys, bridging in-game slang and internal naming.

Overview - Koog

docs.koog.ai

Crucially, the assistant is bound by the same guardrails as a human editor. It works against the staging branch, every change it makes shows up in the same staging-versus-production diff, and promoting anything to production still goes through the same permissioned, audited step. That is what makes us comfortable letting it touch configuration at all: a wrong move is caught at the diff, not in the live game.

Within those bounds, the workflows we are most interested in are the large-scope ones that are tedious and error-prone by hand – the cases where having the whole value tree in context pays off. Asking it to “Analyze the Knold economy and compare the input and output of currency” to get a first-pass breakdown across the relevant values; handing it a migration like “Migrate the individual expressions for the monster types into the new dedicated monster type. I’ve already done it for the zombie” and letting it carry an established pattern across the rest; or having it plot a value’s curve over its variables so a balancing call can be made from a graph rather than raw numbers. These are promising rather than proven – a designer still reviews the diff before anything ships – but they point at where we think the value is.

A conversation with the Contentplate assistant that responds with an interactive Chart.js plot of a value over its variables.
Asked to plot a value, the assistant responds with an interactive Chart.js chart of the curve over its variables — a balancing decision made by looking at a graph instead of reading raw numbers.

So we treat it as an interface on top of Contentplate, not as the point of it. The real work is the typed value model, the evaluator and the live cache; the assistant is just another client of that API, aimed at the same goal as the rest of the system – lowering the barrier to working with our game configuration.

How It Fits Together

Pulling back, Contentplate is a few cooperating parts. On the editing side there is a Ktor/Kotlin backend exposing a REST API over the value store, a Nuxt web app for our designers, and the assistant tooling on top. On the consuming side, the game server pulls everything in through the client library, whose stores resolve references, evaluate expressions, and cache results, while a change-stream subscriber keeps that cache in sync with MongoDB in real-time.

That separation is what makes the system robust: the database is the single source of truth, the backend guards who may change it, and the client library makes the values feel like ordinary constants to the game code – constants that just happen to update themselves. It slots neatly alongside the rest of our infrastructure, the same way our SHARD format and ConfigPositions do.

The Trade-offs

None of this is free, and moving configuration into a typed, expression-based system buys its benefits with a real set of costs. It is worth being honest about them.

The most obvious one is that expressions can grow into code. A one-line formula is a joy, but the moment a value needs a case distinction it gets denser fast. A damage curve that behaves differently per tier ends up looking like a chain of nested conditionals, each branch pulling in its own references and functions:

if(level < 10,
   baseDamage * {dungeon_low_multiplier},
   if(level < 20,
      baseDamage * {dungeon_mid_multiplier} + bonus,
      max(baseDamage * {dungeon_high_multiplier}, {dungeon_damage_floor})))

That is real branching logic, with several nested values and functions, expressed in a language that has none of the tooling – syntax highlighting for the nesting, a debugger, a type checker – that a programming language would give it. References and helpers keep things DRY, but they do not stop a value from becoming genuinely hard to reason about. We rely on convention and review here more than on hard guard rails.

The flip side is debugging. When a formula fails – a missing variable, a broken reference, a type that doesn’t fit – it fails at evaluation time on the game server rather than at compile time in an IDE. To be fair, the failure itself is reported cleanly: Contentplate flags the value as faulty and tells you exactly what went wrong – which variable was missing, which reference didn’t resolve – so reading the cause is no harder than reading a stack trace, and the store falls back instead of taking down a server. The genuinely hard case is the opposite one: a formula that is technically valid but logically wrong. A subtly off number evaluates cleanly, nothing is flagged, and the only thing that catches it is a human noticing the game feels off.

There is also a performance cost to the always-live model, though in practice it is far smaller than you might expect. Every read resolves references and re-evaluates the expression, where a plain constant would do no work at all – but the in-memory cache keeps the data local, and the evaluation itself is cheap. In our measurements it accounts for under 0.1 % of overall computation time: real, but negligible. We still intend to claw even that back with expression-level caching (see below), but it has never been a bottleneck in production.

Schema changes are manual. Developers own the types and keys, and changing the shape of a type means migrating every value of that type by hand or with a one-off script; there is no automatic schema migration. Renaming a key is similarly a coordinated effort, because keys are referenced both from game code and from inside other expressions, and nothing refactors across that boundary for you. And testing a formula is not the same as testing code: a designer’s expression isn’t covered by our test suite the way a function would be. The safety net is staging, the diff, and the faulty flag rather than CI – good enough in practice, but a deliberate trade of compile-time guarantees for live iteration.

We made these trades on purpose, because for balancing work the iteration speed is worth far more than the guarantees we gave up. But they are trades, and a team with different constraints might weigh them differently.

What’s Next

Contentplate is already a core part of how we work, but we are far from done with it. A few of the things we still plan to improve:

  • Further caching for expressions. Today every read resolves references and evaluates the expression afresh. We want to add a second caching layer keyed on the input: if a value’s expression hasn’t changed and the variables it is evaluated against carry the same values as a previous call, we can skip the computation entirely and return the cached result.
  • Our own expression engine. Number values currently lean on exp4j, which is officially deprecated. We plan to build our own implementation tailored to Contentplate, with more modern AST extraction, optimized evaluation, and an improved, more idiomatic Kotlin syntax.
  • Automatic schema migrations. Changing the shape of a type is a manual step today (see the trade-offs above). We want Contentplate to carry versioned type schemas and apply migrations automatically, so reshaping a type rewrites every value of that type in a safe, reviewable pass instead of a hand-written one-off script.
  • A more capable AI assistant. The assistant is the area we are most excited to push further: Retrieval-Augmented Generation (RAG) – pulling in relevant snippets from our content and documentation at query time so the agent grounds its answers in real data instead of guessing – long-term memory that persists context and decisions across sessions rather than starting fresh each time, specialized graph logic for reasoning about reference chains and dependencies, and richer visualization options. We also want to put it under test with Dokimos – our evaluation harness – to guard against regressions and steadily improve the quality of its results over time.

Was It Worth It?

For us, yes. Moving our game configuration into Contentplate changed how we work day to day. Balancing is no longer a development task gated behind builds and deploys – it is a live, iterative activity that our game designers own end to end. They can experiment on staging, see precisely what they are about to change, and ship it to production in seconds, all while watching the game respond.

It took real effort to get there: the typed value model, the evaluator, the live cache, the previews and the branch workflow all had to be built and then trusted with live games. But the payoff is a team that moves faster and steps on each other less. Together with ConfigPositions, which hands the same kind of control to our builders, and our SHARD format, which underpins how our worlds are stored and loaded, Contentplate is one of the tools that helps our small team get more done.

Want To Know More?

We’re constantly working on exciting stuff like this and would love you to take part in the development of JustChunks. If you’re just interested in more JustChunks-related development or want to get in touch with the community, feel free to read about our server news here or hop on our discord server!

Join the JustChunks Discord Server!

Discover Minecraft from a different perspective and experience spectacular game experiences! | 375 members

Discord

Themen

  • Minecraft
  • Development
← Zurück zu allen Beiträgen