Dynamic Strings
DynamicString wraps an NBT slot inside a shared storage (kore_string_lib:memory, path heap.<name>) so datapack strings can be manipulated with an idiomatic Kotlin API: substring, split, join, replace, trim, padStart, uppercase, and the rest of the kotlin.String vocabulary.
Only the helpers you actually call are materialized as mcfunction files, so an unused API costs nothing in the generated pack.
Quick start
Concepts
Three things are worth knowing before reading the rest of this page.
Everything lives in one storage. Strings sit at heap.<name>, lists at lists.<name>, macro arguments at args.<helper>, and scratch values at tmp.<key>. Every root is configurable, see Customising the runtime.
Results come back as scoreboard scores, not values. A datapack cannot return a value, so every helper that computes something (length, indexOf, contains, count, equalsTo, …) writes it into a fake-player score on the kore_string_len objective and returns a DynamicStringResult. Predicates all use the same convention: 1 means true, 0 means false. then and otherwise branch on it, matching takes any range.
A DynamicStringResult is a ScoreboardEntity, so it also feeds straight into any helper taking a runtime score, and into the whole scoreboard DSL:
Most operations write into a target. Helpers that produce a new string take an optional target: DynamicString parameter defaulting to this, so greeting.uppercase() mutates in place while greeting.uppercase(buffer) leaves the source untouched. Each one also has an expression form (uppercased(), trimmed(), repeated(3), paddedStart(4), …) writing into a fresh anonymous slot and returning it, so transformations chain without declaring a scratch string.
Registering the module
One call registers the runtime, declares the kore_string_len objective and returns the DynamicStringRuntime that every helper self-registers against:
Call it before any string helper, otherwise dynamicString throws. Names are allocated against the runtime, so a duplicate name fails at generation time instead of silently sharing a slot. The kore_string_ prefix is reserved for the module's own scratch slots and is rejected for user names.
Creating and mutating a string
Display it with asChatComponents(), which builds an NBT chat component. interpret defaults to true, so the content is parsed as a chat component itself; pass false to print it as raw text:
Length
length() stores the character count into the configured objective and returns it as a DynamicStringResult. Pass a custom holder when several lengths must stay alive at once:
Substring, take and drop
Indexing slices into a fresh anonymous slot and returns it, with inclusive Kotlin range semantics on the indices:
The named forms write into an explicit target (defaulting to this) instead of allocating one, with kotlin.String.substring semantics (start inclusive, end exclusive, end = null meaning "to the end"). All of them compile down to a single data modify ... set string:
takeLast and dropLast measure the length at runtime, so they cost one extra execute store result and go through the substring macro. Runtime bounds held in scores use the dynamic variants:
Concatenation
build rewrites a string from any number of operands: literals, characters, other dynamic strings and scores, each pushed with a +. Consecutive literals are folded at generation time into a single set value, the first operand seeds the target instead of being appended to it, and writing into one of the operands is safe because it is read before it is overwritten.
An empty block clears the target, and plus leaves both its operands untouched.
The named forms cover prepending and partial operands, which have no operator:
Comparisons
Every comparison writes a 0 / 1 flag into a holder on the length objective and returns a DynamicStringResult. eq, startsWith and endsWith are infix, so a comparison reads like a condition:
equalsTo is the non-infix form of eq, and takes an optional resultHolder when several equality results must stay alive at the same time.
startsWith and endsWith reject an empty literal, which would always match.
Searching
All four share one recursive find controller that walks the string one offset at a time, so their cost grows with the length of the haystack. An empty needle is rejected.
Replacing
Both the needle and the replacement accept a DynamicString, so a find-and-replace can be driven entirely by values computed in game. An empty runtime needle replaces nothing instead of looping forever:
replace resumes the search past the text it just inserted, so a growing replacement such as replace("a", "aa") terminates instead of matching its own output forever.
Case conversion (ASCII)
A translation table is written to tables.<direction> on world load, and each character is mapped through a single set from on that table instead of a 26-branch if chain. capitalize and decapitalize map their single character directly through the table, skipping the per-character loop entirely.
Characters with no table entry, including every non-ASCII one, come out unchanged. Double quotes and backslashes skip the lookup because they cannot be used as an NBT path key; neither has a case, so the result is the same either way.
Trim, pad and repeat
The width and the count also accept a score, so a progress bar or an aligned column can be sized in game:
The whitespace set is DynamicStringConfig.trimWhitespace, see below.
Reverse
A tail-recursive macro walks the string from its last character to its first, appending each one to an accumulator. The recursion depth equals the string length, so it is bounded by the max_command_sequence_length game rule (65 536 by default).
Splitting and lists
Both clear the target list first and reuse the find / substring primitives. A runtime delimiter that turns out to be empty leaves the list empty rather than looping forever.
KoreStringList wraps an NBT list at lists.<name> and offers the usual list primitives:
forEach generates a dedicated macro loop per call site and binds the current element into the DynamicString you pass as the cursor. Inside the block the Function receiver is active, so any Kore DSL call is valid.
Joining a list back into a string
join is the inverse of split: it walks the list once and appends every element into a target string, inserting the separator between them and wrapping the result with an optional prefix / postfix, exactly like kotlin.collections.joinToString.
The target is overwritten, an empty list leaves it as prefix + postfix, and the separator only lands between elements, never at the ends. Paired with split, it round-trips a list through a single string, which is how a list fits in an item name, a sign line or one storage field:
Numbers and scores
Scores are the only values a datapack can compute, so DynamicString converts in both directions. setFrom(score) renders a score as text, toScore parses the text back into a score:
This is what makes a formatted clock, a leaderboard line or a numeric config value work without a score chat component, and the result stays a string that padStart, split or join can keep working on.
Parsing and serialization
parseTo evaluates the string content as SNBT and writes the resulting value anywhere, which covers numbers (42, 3.14, 1L), booleans, quoted strings and full NBT literals such as {a: 1, b: [1,2,3]}. setFromNbt goes the other way, turning an arbitrary NBT value into its textual form:
Customising the runtime
Every storage slot, scoreboard objective and NBT root is configurable through DynamicStringConfig. This matters when several modules share a datapack, or when you want the module to live inside an existing storage convention:
trimWhitespace belongs to the same config. Its entries land verbatim inside the generated commands, so they use SNBT escapes, not Kotlin ones:
Every field defaults to the matching OopConstants.string* value, so changing one of those moves the default for every datapack in the project instead of for a single one.
End-to-end example: a typewriter dialogue box
Revealing a line of dialogue one character at a time is a staple of adventure maps, and it is the exact thing vanilla cannot do: a text component is fixed at write time, so the usual workarounds are one hardcoded tellraw per frame ("T", "Th", "The", …) or shipping a third-party string library such as String-Parser and driving it by hand.
With a DynamicString the effect is a growing substring, and the dialogue text stays a plain Kotlin string:
The whole effect is a handful of commands per tick, one of them the substring macro, and the text is never duplicated: changing the line, translating it, or feeding it from a sign, a book or a KoreStringList of lines only touches line. substringDynamic reads its bounds from scores, so the same function drives a slow reveal, an instant skip (set cursor to the length) or a scrolling window (advance start too).
Another example: parsing a config line
A pack setting is the other everyday fit: admins edit one string with a single /data modify, the pack turns it into typed values. The alternative is one execute if data per accepted value, hardcoded, which caps the setting to the values you thought of when writing the pack.
The lowercase pass makes Spawn_Radius and spawn_radius the same key, and trim absorbs the spaces around each separator. An entry without = leaves index 1 of the pair out of range, so check pair.size() first when the input is player-written.
Every helper is generated once per datapack, so calling the same pipeline from several places adds no extra function.
Operator reference
Every operator is a thin alias over the named helper, so it carries the exact same cost.
| Operator | Equivalent | Notes |
|---|---|---|
s += "x" / += 'x' |
s.append("x") |
also accepts a DynamicString or a ScoreboardEntity |
s -= "x" |
s.replace("x", "") |
strips every occurrence, literal or dynamic needle |
s *= 3 |
s.repeat(3) |
also accepts a runtime count as a ScoreboardEntity |
a + b |
copy of a then append(b) |
expression form, writes into a fresh anonymous slot |
s[2] / s[1..3] |
charAt / substringTo |
inclusive range, writes into a fresh anonymous slot |
list += "x" |
list.append("x") |
also accepts a DynamicString |
list[1] |
list.elementAt(1, target) |
writes into a fresh anonymous slot |
a eq b |
a.equalsTo(b) |
infix, like startsWith, endsWith and contains |
Anonymous slots come from tempString() (or tempDynamicString() on a DataPack), which is also the helper to call when a pipeline needs a scratch string of its own. They live in the same heap as named strings, under the reserved kore_string_temp_<n> prefix, and are never reused across call sites.
Cost and limits
Helpers fall into three tiers, worth keeping in mind when a string is long or a helper runs every tick:
| Tier | Helpers | Cost |
|---|---|---|
| Constant | set, setFrom, clear, substring, substringTo, take, drop, charAt(Int), append, prepend, build |
1 to 3 commands, no macro |
| One macro call | substringDynamic, takeLast, dropLast, capitalize, decapitalize, parseTo, setFromNbt, setFrom(score), toScore |
a handful of commands plus one function call |
| Recursive | reverse, indexOf, contains, count, replace, split, join, toList, uppercase, lowercase, trim, repeat, padStart, padEnd |
one function call per character, offset or element |
Recursive helpers are bounded by the max_command_sequence_length game rule (65 536 by default), which is far above any realistic string length but is the hard ceiling.
The module handles ASCII text. Case conversion only maps a-z / A-Z, and indices are NBT string indices, so characters outside the Basic Multilingual Plane do not behave like single characters.
See also
- Scoreboards – consume the length / find / predicate score holders returned by these helpers.
- Macros – the underlying mechanism used by every dynamic helper.
- Data command – NBT read/write via Kore's
datacommand helpers.
