String utilities you reach for across every system in your game: stable cross-platform hashing, null-safe guards, ordinal comparisons, invariant number formatting, rich-text tag helpers, filename sanitization, and column-aligned tabular output for debug overlays and CLI commands.
Summary
The Scylla string utilities are tested string operations that come in handy for everyday tasks, for example for stable hashing that survives a rebuild, null-safe guards, ordinal comparisons that ignore locale, and so on. StringUtil gathers all of it, each operation available as a plain static call or a this string extension, whichever reads better where it is used. A second component, TabularText, handles column-aligned table output for logs and consoles.
Three things make them worth reaching for over the raw System.String API, and the first is determinism where the platform provides none. string.GetHashCode is randomized per process in .NET Core and varies across machines, so the same string hashes differently every run; StringUtil.GetStableHash32 is FNV-1a (Fowler-Noll-Vo 1a) instead, identical on every build and every machine, which is what cache keys, content IDs, and serialization-surviving dictionary keys actually need. Culture is the second trap it closes: EqualsOrdinal and its StartsWithOrdinal / EndsWithOrdinal / ContainsOrdinal siblings compare by code point, so a build that lands on a Turkish locale never watches "I".ToLower() stop equaling "i", and ToStringInvariant(1.5f) writes "1.5" everywhere rather than "1,5" on a German machine. The rest is just reach: sixty-plus methods for the one-liners everyone re-implements, among them EmptyIfNull, HasText, NullIfWhiteSpace, Truncate, SafePadCenter, IndentLines, and SanitizeForFileName.
Building a TabularText runs through its fluent builder: column count, per-column alignment (Left, Right, Center, Justified), optional per-column sorting, headers and footers that bring their own dividers, and truncation via WithMaxColumnLength, all set before you add rows and render. Column widths come from the content, so nothing needs measuring by hand. The result comes back one-shot as a string or appended straight into an existing StringBuilder, which is why it turns up in debug dumps, in-game CLI commands, performance overlays, and anything else that boils down to n columns by m rows.
String code is everywhere in a game project. Save game keys, asset registry IDs, log message formatting, UI label preparation, debug console output, console table dumps, JSON marshalling, configuration-file parsing, filename generation: every one of these is a place where the standard System.String API has a sharp edge or a missing convenience. Routing through StringUtil once at the call site collapses each of those into a single, tested operation that behaves identically on every platform and across every build, with predictable semantics for null and whitespace inputs.
Use the string utilities for:
- Stable hashing for cache keys, content IDs, dictionary keys, and anything that needs to survive serialization or cross-machine identity.
- Null and whitespace handling:
EmptyIfNullfor guard-free concatenation,NullIfEmptyfor “missing or empty” semantics,HasTextfor “non-empty after trim” checks. - Ordinal comparison and search without culture-sensitive surprises (Turkish locale
I/i, Germanss/SS, etc.). - Invariant formatting of numbers for stable JSON, configs, and save files.
- Padding and truncation that survives strange inputs (null, length 0, content already longer than the target).
- Rich text helpers for TextMeshPro and Unity UI: bold, italic, underline, size, color, plus escape and strip.
- Sanitization for filenames (cross-platform) and JSON string literals.
- Indented multi-line text for debug dumps, generated source, and nested log entries.
- Tabular layouts for debug overlays, CLI command output, and inspector tooling.
Features
- Deterministic stable hashing.
GetStableHash32is FNV-1a, identical on every platform and across every process. Replacesstring.GetHashCodefor cache keys, content IDs, and dictionary keys that must survive serialization. Case-insensitive overload viaignoreCase: true. - Sixty-plus operations on
StringUtil. Hashing, null/whitespace handling, ordinal comparison and search, substring, replace, line-ending normalization, invariant formatting and parsing, padding, truncation, sanitization, JSON escape, rich text. The catalog covers the operations that get hand-rolled in every project. - Null and whitespace helpers.
EmptyIfNullfor guard-free concatenation,NullIfEmptyandNullIfWhiteSpacefor “missing or empty” semantics,HasTextfor “non-empty after trim”. Pre-replaces thestring.IsNullOrEmpty/IsNullOrWhiteSpace/Trimchain that every project rediscovers. - Ordinal comparison without culture surprises.
EqualsOrdinal,StartsWithOrdinal,EndsWithOrdinal,ContainsOrdinal, case-insensitive variants. Bypasses the Turkish-localeI/iand Germanss/SSproblems that culture-sensitive comparison ships into production. - Invariant formatting and parsing.
ToStringInvariant(float/double/int/...)writes"1.5"everywhere, never"1,5"on German systems.TryParseFloatInvariant,TryParseDoubleInvariant,TryParseIntInvariantcover the reverse direction. Save files, config files, and JSON stay portable. - Padding and truncation.
SafePadLeft,SafePadRight,SafePadCenter,Truncate(s, max),TruncateWithEllipsis(s, max). Survives null inputs, length-zero inputs, and content already longer than the target without throwing. - Rich-text helpers for TextMeshPro.
Bold,Italic,Underline,Size,Color, plusEscapeRichTextandStripRichText. The right primitive for log lines, in-game labels, and tooltips that need inline styling without manual tag concatenation. - Sanitization for filenames and JSON.
SanitizeForFileNamestrips characters that any major OS rejects;EscapeJSONStringproduces a valid JSON string literal from arbitrary input. Pair with Files IO and Serialization at the call sites that need them. - Line-ending and indentation control.
NormalizeLineEndings,IndentLines(s, prefix),Dedent(s). Useful for cross-platform text I/O, generated source, multi-line logs, and embedded snippets. TabularTextfor column-aligned tables. Sealed class with a fluentBuilder: column count, per-column alignment (Left/Right/Center/Justified), per-column sorting, headers, footers, dividers, fills, row leading, and per-column truncation. The right tool for debug overlays, CLI command output, and inspector tools.TextAlignmentenum. Four values (Left,Right,Center,Justified) used byTabularTextand by other fixed-width text rendering utilities. Framework-level enum, independent of Unity’sTMPro.TextAlignmentOptions.- Extension and static call shapes. Most operations read naturally as extensions (
name.SanitizeForFileName(),path.NormalizeLineEndings()); the few “convert this thing to a string” operations stay as plain static calls (StringUtil.ToStringInvariant(1.5f)). Pick the form that reads better at the call site.
Components
The public surface is split across one static utility class with about sixty methods, one tabular-rendering class with a fluent builder, and one enum. Several methods are exposed as this string extension methods so the call site reads naturally; the rest are plain static methods because their primary input is something other than the string itself.
A few terms worth defining before you dive in. Ordinal comparison treats two strings as identical byte sequences without applying culture-aware lowering, accent folding, or character normalization. Invariant culture is the .NET pseudo-culture that uses . as the decimal separator, 1234 (no thousands separator) for integers, sortable date formats – identical on every machine and runtime. Rich text is the inline markup used by TextMeshPro and Unity’s UI.Text for inline styling (<b>, <i>, <color>, <size>).
| Component | Role | Notes |
|---|---|---|
StringUtil | Static utility class with the small helpers reached for everywhere | About sixty methods grouped by purpose: hashing, null handling, ordinal comparison, substring, replace, line endings, invariant formatting and parsing, truncate/pad, sanitization, JSON escape, rich text. Most are extension methods. |
TabularText | Sealed class for column-aligned tabular layout | Build via constructor or fluent Builder. Add rows via AddRow(params string[]). Render via ToString() or WriteTo(StringBuilder). Configurable alignment, sorting, headers, footers, dividers, fills, per-column truncation. |
TextAlignment | Enum used by TabularText for column alignment | Four values: Left, Right, Center, Justified. Framework-level enum; not tied to Unity’s TMPro.TextAlignmentOptions. Used by TabularText and by other text rendering utilities that align inside a fixed-width region. |
StringUtil is the primary entry point. Most methods read naturally as extension calls (name.SanitizeForFileName(), path.NormalizeLineEndings(), value.SafePadLeft(8)); a few stay as plain static calls when their semantics is “convert this thing to a string” rather than “transform this string” (StringUtil.ToStringInvariant(1.5f), StringUtil.GetStableHash32("level-3"), StringUtil.TryParseFloatInvariant("1.5", out var v)).
Recipes
These recipes cover every public method on StringUtil, every entry point on TabularText, and every TextAlignment value. Each is a self-contained answer to a single practical problem, so scan the names and jump to the one you need. The stable-hashing recipe comes first because it establishes the discipline the others build on.
Generate stable cross-platform hash keys
Problem. You want to key a runtime dictionary by an asset name, a save-slot identifier, or a content ID that has to survive a process restart, a save-file round-trip, or a cross-machine sync. You reach for string.GetHashCode and it works on your machine – then QA reports a cache miss on every load after the first session, because .NET Core randomizes the hash seed per process. The same string hashes to a different value every time the game starts.
Solution. GetStableHash32 is FNV-1a 32-bit, deterministic, and identical across platforms, runtimes, and process restarts. The ignoreCase overload uppercases each character via char.ToUpperInvariant before mixing, so case-folded equality is also reproducible. Use the result as a dictionary key, a cache key, or a content identifier; never use string.GetHashCode for any of these.
using Scylla.Core.Util;
/* Deterministic 32-bit hash: same input always produces the same output, on
* every platform, in every process. Suitable for save-file keys and content IDs. */
uint key = StringUtil.GetStableHash32("level-3");
/* Case-insensitive variant. The same string with different casing produces the
* same hash; useful when input casing is not authoritative (e.g., asset paths). */
uint folded = StringUtil.GetStableHash32("level-3");
uint foldedUC = StringUtil.GetStableHash32("LEVEL-3", ignoreCase: true);
/* folded == foldedUC */
/* Null and empty inputs return 0. The caller decides how to disambiguate "this
* key has hash 0" from "no string given"; usually a length check answers it. */
uint nullHash = StringUtil.GetStableHash32(null); /* 0 */
uint emptyHash = StringUtil.GetStableHash32(""); /* 0 */
Guard display strings against null and whitespace
Problem. You’re formatting an NPC’s name, a save-slot title, or any other string that arrives from config, user input, or an optional data field. It might be null. It might be empty. It might be four spaces that count as “nothing” for display purposes but break a null check. The standard approach requires a ladder of IsNullOrEmpty, IsNullOrWhiteSpace, and Trim calls at each call site; these helpers collapse that pattern to one expressive call.
Solution. Six small helpers cover the recurring “what does this nullable string actually contain?” question. EmptyIfNull converts a null to "" for safe concatenation. NullIfEmpty and NullIfWhiteSpace go the other way, returning null when the string is empty or whitespace, useful for “missing” semantics. TrimToEmpty and TrimToNull combine trimming with the empty/null reduction. HasText is the affirmative check: true when the string is non-null and has at least one non-whitespace character.
string maybeName = LoadFromConfig("playerName"); /* may be null */
/* EmptyIfNull: safe concatenation without a null-check ladder. */
string greeting = "Welcome, " + maybeName.EmptyIfNull() + "!";
/* NullIfEmpty: empty string -> null. Useful for libraries that distinguish
* "no value" from "empty value". */
string trimmedTitle = userInput.NullIfEmpty();
/* NullIfWhiteSpace: same idea but whitespace-only also collapses to null. */
string clean = " ".NullIfWhiteSpace(); /* null */
/* TrimToEmpty / TrimToNull: trim plus the empty/null reduction. */
string a = " hello ".TrimToEmpty(); /* "hello" */
string b = " ".TrimToEmpty(); /* "" */
string c = " ".TrimToNull(); /* null */
string d = " hello ".TrimToNull(); /* "hello" */
/* HasText: the affirmative check. true iff non-null and contains non-whitespace. */
if (maybeName.HasText())
{
/* Real value, ready to use. */
Display(maybeName);
}
Compare asset paths and save keys without culture bugs
Problem. You’re checking whether a save-file path ends with .sav, whether a config key matches "--debug", or whether an asset registry entry starts with "backup-". C#’s default string operators (==, Contains, StartsWith, EndsWith, IndexOf) use culture-sensitive comparison. On a Turkish locale that means "I".ToLower() returns the Turkish dotless i, not "i", and a comparison that works on your English machine silently breaks on a customer’s machine.
Solution. The Ordinal and OrdinalIgnoreCase family skips the culture layer entirely. Every comparison is a pure byte-level operation, safe on every locale. Use these for any non-UI string: save data, config keys, asset paths, file extensions, command-line tokens, and protocol literals.
/* Equality. EqualsOrdinal is case-sensitive; EqualsOrdinalIgnoreCase folds case. */
bool exact = StringUtil.EqualsOrdinal("level-3", "level-3"); /* true */
bool fold = StringUtil.EqualsOrdinalIgnoreCase("LEVEL-3", "level-3"); /* true */
/* Prefix and suffix tests as extension methods on string. */
if (saveName.StartsWithOrdinal("backup-"))
{
/* Treat as backup save. */
}
if (path.EndsWithOrdinalIgnoreCase(".PNG"))
{
/* Image file. */
}
/* Substring search. Pass a StringComparison value to control case folding. */
int at = log.IndexOf("ERROR", StringComparison.OrdinalIgnoreCase);
/* Contains variants. */
if (commandLine.ContainsOrdinal("--debug"))
{
EnableDebug();
}
if (json.ContainsOrdinalIgnoreCase("\"version\""))
{
/* Version field present. */
}
Slice a string safely without index-out-of-range crashes
Problem. A lot of string code reaches for Substring, and a fair amount of that code eventually crashes on an out-of-range index because the input changed shape between two releases. A save-slot identifier that used to be "player-1234" gets a null entry in an older save file, or a config value shrinks; either way, your Substring(7) call throws.
Solution. SafeSubstring is the bounds-safe version of string.Substring: out-of-range startIndex returns the empty string instead of throwing, and length is clamped against the remaining characters. Left(n) and Right(n) are the convenience shortcuts for the two common slice patterns. All three are extension methods.
string id = "player-1234";
/* SafeSubstring: out-of-range indices and lengths produce the empty string. */
string mid = id.SafeSubstring(startIndex: 7); /* "1234" */
string tail = id.SafeSubstring(startIndex: 7, length: 2); /* "12" */
/* Out of range: empty string, never an exception. */
string oob = id.SafeSubstring(startIndex: 100); /* "" */
string clamped = id.SafeSubstring(startIndex: 7, length: 1000); /* "1234" */
/* Left and Right: take the first/last N characters. Same bounds-safety as
* SafeSubstring; values larger than Length return the full string. */
string prefix = id.Left(6); /* "player" */
string suffix = id.Right(4); /* "1234" */
string longer = id.Left(100); /* "player-1234" (clamped to Length) */
Replace and count substrings across locales
Problem. You want to replace every space in a filename with _, strip \r\n from config text loaded on Windows, or count how many \n characters appear in a log dump to measure line count. Sometimes the match should be case-insensitive. The built-in string.Replace does not take a StringComparison argument, so you end up with a loop or a regex that does more than the job requires.
Solution. Replace, Remove, and CountOccurrences each take a StringComparison argument for explicit case folding. Remove is syntactic sugar for Replace(value, "", comparison). All three are extension methods on string.
/* Case-sensitive replace. */
string clean = filename.Replace(" ", "_", StringComparison.Ordinal);
/* Case-insensitive replace. */
string sanitized = source.Replace("DEBUG", "[DEBUG]", StringComparison.OrdinalIgnoreCase);
/* Remove all occurrences of a substring. */
string stripped = json.Remove("\r\n", StringComparison.Ordinal);
/* Count occurrences. */
int count = text.CountOccurrences("\n", StringComparison.Ordinal); /* line count - 1 */
Normalize line endings for cross-platform text I/O
Problem. You’re loading a text file on Windows that another developer edited on macOS, and the diff shows every single line changed because \r\n became \n. Or you’re generating a code snippet with embedded newlines and need to ensure the output uses the right line ending for the target platform. You also want multi-line log entries to indent consistently under the log receiver’s timestamp prefix.
Solution. NormalizeLineEndings converts \r\n and \r to a single specified newline (default \n), useful when text crosses Windows and Unix boundaries. IndentLines prepends an indent string to every line, optionally including empty lines, useful for formatted log output and code generation. Both are extension methods.
string mixedNewlines = "alpha\r\nbravo\rcharlie\ndelta";
/* Normalize to LF. */
string normalized = mixedNewlines.NormalizeLineEndings();
/* "alpha\nbravo\ncharlie\ndelta" */
/* Normalize to CRLF (Windows). */
string winNewlines = mixedNewlines.NormalizeLineEndings(newline: "\r\n");
/* Indent every line with two spaces. Empty lines are not indented by default. */
string log = "Loaded:\nlevel-1\nlevel-2\n\nDone.";
string indented = log.IndentLines(indent: " ");
/* " Loaded:\n level-1\n level-2\n\n Done." */
/* Indent every line including empty lines. */
string indentedAll = log.IndentLines(indent: " ", indentEmptyLines: true);
Format and parse numbers portably across locales
Problem. Your game writes a float to a save file as "1.5", and a player on a German system loads it back and gets a parse failure because the parser expects "1,5" – German systems use a comma as the decimal separator. The same bug hits JSON config, network packets, and any other string that carries a number across a process or machine boundary.
Solution. The invariant variants always use . for the decimal separator, , for nothing, and ASCII sign characters. Use them for any value that crosses a process or platform boundary. The Try* variants never throw; check the boolean return before reading the out value.
/* Convert to string with invariant culture. */
string i = StringUtil.ToStringInvariant(12345);
string l = StringUtil.ToStringInvariant(12345L);
string f = StringUtil.ToStringInvariant(1.5f); /* "1.5", never "1,5" */
string d = StringUtil.ToStringInvariant(1.5);
string m = StringUtil.ToStringInvariant(1.5m);
string o = StringUtil.ToStringInvariant((object)42); /* dispatches on runtime type */
/* Parse with invariant culture. The Try variants return false on failure
* rather than throwing. */
if (StringUtil.TryParseInt("42", out int level))
{
/* level == 42 */
}
if (StringUtil.TryParseFloatInvariant("1.5", out float speed))
{
/* speed == 1.5f */
}
/* TryParseBool accepts "true"/"false" plus a handful of common aliases like
* "yes"/"no", "1"/"0", "on"/"off" (case-insensitive). */
if (StringUtil.TryParseBool("yes", out bool enabled))
{
/* enabled == true */
}
/* TryParseEnum<TEnum> uses Enum.TryParse with the ignoreCase parameter exposed
* as a method argument. */
if (StringUtil.TryParseEnum<TextAlignment>("Right", ignoreCase: true, out TextAlignment align))
{
/* align == TextAlignment.Right */
}
Truncate UI labels and pad fixed-width text columns
Problem. There’s always a UI label that doesn’t quite fit its allocated width, and there’s always a debug overlay or CLI column that needs values to line up neatly. You either truncate with an ellipsis for the display case or pad to a fixed width for the layout case. Both operations need to handle null inputs, empty strings, and content that is already wider than the target without crashing.
Solution. Truncate(maxLength, ellipsis) shortens a string to at most maxLength characters, appending the ellipsis when truncation occurs. The default ellipsis is "...". SafePadLeft, SafePadRight, and SafePadCenter add padding to a target width using a configurable fill character. The “Safe” prefix means out-of-range inputs (null, already-longer-than-target) are handled gracefully without throwing.
/* Truncate with default ellipsis. */
string short1 = "This is a long description.".Truncate(maxLength: 15);
/* "This is a lo..." */
/* Truncate with custom ellipsis. */
string short2 = "lengthy".Truncate(maxLength: 5, ellipsis: "..");
/* "len.." */
/* No truncation when the input already fits. */
string passthrough = "short".Truncate(maxLength: 100);
/* "short" */
/* Padding to a fixed width. The character argument defaults to space. */
string right = "12".SafePadRight(totalWidth: 6); /* "12 " */
string left = "12".SafePadLeft (totalWidth: 6); /* " 12" */
string mid = "12".SafePadCenter(totalWidth: 6); /* " 12 " */
/* Custom fill character. Useful for ASCII art or terminal layouts. */
string dotted = "12".SafePadLeft(totalWidth: 6, paddingChar: '.'); /* "....12" */
string spaced = "x".SafePadCenter(totalWidth: 5, paddingChar: '-'); /* "--x--" */
/* Safe inputs: null and already-longer-than-target inputs are no-ops. */
string nullPad = ((string)null).SafePadLeft(5); /* " " */
string overlong = "abcdef".SafePadLeft(totalWidth: 4); /* "abcdef" */
Sanitize filenames, escape JSON, and style TMP labels
Problem. You’re generating save-file paths from player-entered names, writing JSON to a config file, and applying inline formatting to in-game UI text. Each of those needs a different string transformation: invalid path characters stripped (portable across Windows, macOS, and Linux), special characters in a JSON value escaped, and TextMeshPro markup tags wrapped around display strings. Doing these with raw string manipulation at every call site is error-prone and doesn’t handle edge cases consistently.
Solution. SanitizeForFileName replaces every character that is invalid on any major platform with a configurable replacement and trims trailing dots and spaces. EscapeJSONString produces a JSON string literal from arbitrary text. The rich-text helpers wrap text in the TextMeshPro tag set. EscapeRichText and StripRichTextTags let you round-trip through fields where the tags should not render.
/* Filename sanitization. The replacement character defaults to '_'. */
string raw = "save: latest/<2026>.dat";
string safe = raw.SanitizeForFileName(); /* "save_ latest_ 2026 .dat" - trailing space trimmed */
/* Custom replacement. */
string hyph = raw.SanitizeForFileName(replacement: '-');
/* JSON escape. Returns a literal that can be wrapped in quotes and parsed. */
string escaped = StringUtil.EscapeJSONString("He said \"hi\"\nthen left.");
/* "He said \"hi\"\\nthen left." */
/* Rich text tag helpers. Each wraps the input in the corresponding TMP tag. */
string b = StringUtil.Bold("Critical!"); /* "<b>Critical!</b>" */
string i = StringUtil.Italic("emphasis"); /* "<i>emphasis</i>" */
string u = StringUtil.Underline("link"); /* "<u>link</u>" */
string s = StringUtil.Strikethrough("removed"); /* "<s>removed</s>" */
string z = StringUtil.Size("BIG", size: 32); /* "<size=32>BIG</size>" */
string c = StringUtil.Color("danger", new Color32(255, 0, 0, 255));
/* "<color=#FF0000FF>danger</color>" */
string cNoAlpha = StringUtil.Color("info", new Color32(0, 128, 255, 255), includeAlpha: false);
/* "<color=#0080FF>info</color>" */
/* Escape rich text characters in user input so the tags do not render as styling. */
string userMessage = "I love <b>games</b> too!";
string escapedMsg = userMessage.EscapeRichText();
/* "I love <b>games</b> too!" - safe to display as a literal */
/* Strip rich text tags from text that contained them. */
string plain = "<b>Title</b> <color=red>warning</color>".StripRichTextTags();
/* "Title warning" */
Build an aligned table for debug overlays and CLI output
Problem. You’re writing a debug console command that reports per-entity stats, a performance overlay that shows module timings, or an inspector dump that lists configuration values. You want the output to align in readable columns without computing column widths by hand or appending spaces everywhere. Every time you add a column or change a value’s length, the manual alignment breaks.
Solution. TabularText computes column widths from content and applies the alignment you configure per column. Construct via the constructor for simple cases, or use the fluent CreateBuilder() for everything else. The builder configures column count (or derives it from the header), per-column alignment, sorting, headers, footers, dividers, fills, row leading, and per-column truncation. Add rows via AddRow(params string[]); render via ToString() or WriteTo(StringBuilder).
using Scylla.Core.Util;
using Scylla.Core;
/* Simplest: construct with a header, add rows, render. The column count comes
* from the header array. */
var table = new TabularText(header: new[] { "Name", "Level", "Health" });
table.AddRow("Alice", "5", "120");
table.AddRow("Bob", "12", "75");
table.AddRow("Carol", "8", "100");
Log.Info("\n" + table.ToString(), LogCategory.Core);
/* Output:
Name Level Health
Alice 5 120
Bob 12 75
Carol 8 100
*/
/* Fluent builder for richer configuration. */
var richer = TabularText.CreateBuilder()
.WithHeader("Name", "Level", "Health")
.WithColumnAlignment(columnIndex: 0, TextAlignment.Left)
.WithColumnAlignment(columnIndex: 1, TextAlignment.Right)
.WithColumnAlignment(columnIndex: 2, TextAlignment.Right)
.WithSorting(sortColumn: 1) /* sort rows by Level column */
.WithFooter() /* the last AddRow call becomes the footer */
.WithDivider(" | ") /* column separator */
.WithFill(' ') /* padding character */
.WithRowLeading(" ") /* indent every row by 4 spaces */
.WithMaxColumnLength(20) /* truncate long cells to 20 chars */
.Build();
richer.AddRow("Alice", "5", "120");
richer.AddRow("Bob", "12", "75");
richer.AddRow("Carol", "8", "100");
richer.AddRow("TOTAL", "25", "295"); /* becomes the footer row */
Log.Info("\n" + richer.ToString(), LogCategory.Core);
/* WriteTo: append into an existing StringBuilder rather than allocating
* a fresh string. The previous overload of ToString(StringBuilder) returns
* the resulting string after appending, useful when both forms are needed. */
var sb = new System.Text.StringBuilder();
sb.AppendLine("Stats:");
richer.WriteTo(sb);
sb.AppendLine("--- end ---");
/* Set per-column alignment after construction. Returns this for chaining. */
richer.SetColumnAlignment(columnIndex: 0, TextAlignment.Right);
/* Clear all rows (keeps header configuration). */
richer.Clear();
/* Inspect shape. */
int cols = richer.ColumnCount;
int rows = richer.RowCount;
int totalWidth = richer.Width;
bool empty = richer.IsEmpty;
bool hasHeader = richer.HasHeader;
bool hasFooter = richer.HasFooter;
Control column alignment in a tabular layout
Problem. You have a TabularText table with a name column, a numeric stat column, and a short status label. Numeric columns read most clearly when right-aligned; name columns read better left-aligned; short status markers can sit centered. You need per-column control, and you want to set alignment at build time through the builder rather than making manual adjustments per row.
Solution. TextAlignment has four values. You set them in the builder via WithColumnAlignment before calling Build, or adjust them after construction via SetColumnAlignment. The four values cover almost every fixed-width text layout you’ll encounter in a game’s debug or CLI output.
/* The four values. */
TextAlignment l = TextAlignment.Left; /* default */
TextAlignment r = TextAlignment.Right; /* common for numeric columns */
TextAlignment c = TextAlignment.Center; /* headers, single-letter status fields */
TextAlignment j = TextAlignment.Justified; /* even spacing across the column width */
/* Apply per-column in a builder. */
var aligned = TabularText.CreateBuilder()
.WithHeader("Item", "Qty", "Note")
.WithColumnAlignment(0, TextAlignment.Left)
.WithColumnAlignment(1, TextAlignment.Right)
.WithColumnAlignment(2, TextAlignment.Center)
.Build();
API Sketch
The public surface is one static utility class, one tabular-rendering sealed class with a fluent builder, and one enum. StringUtil is in the Scylla.Core.Util namespace; TabularText is in Scylla.Core.Util; TextAlignment is in Scylla.Core.
namespace Scylla.Core.Util
{
public static class StringUtil
{
/* Hashing (deterministic FNV-1a). */
public static uint GetStableHash32(string s, bool ignoreCase = false);
/* Null / empty / whitespace. Extension methods on string. */
public static string EmptyIfNull (this string s);
public static string NullIfEmpty (this string s);
public static string NullIfWhiteSpace (this string s);
public static string TrimToEmpty (this string s);
public static string TrimToNull (this string s);
public static bool HasText (this string s);
/* Ordinal comparison. */
public static bool EqualsOrdinal (string a, string b);
public static bool EqualsOrdinalIgnoreCase (string a, string b);
public static bool StartsWithOrdinal (this string s, string prefix);
public static bool StartsWithOrdinalIgnoreCase(this string s, string prefix);
public static bool EndsWithOrdinal (this string s, string suffix);
public static bool EndsWithOrdinalIgnoreCase (this string s, string suffix);
public static int IndexOf (this string s, string value, StringComparison comparison);
public static bool ContainsOrdinal (this string s, string value);
public static bool ContainsOrdinalIgnoreCase (this string s, string value);
/* Substring. */
public static string SafeSubstring(this string s, int startIndex, int length = int.MaxValue);
public static string Left (this string s, int count);
public static string Right (this string s, int count);
/* Replace / remove / count. */
public static string Replace (this string s, string oldValue, string newValue, StringComparison comparison);
public static string Remove (this string s, string value, StringComparison comparison);
public static int CountOccurrences(this string s, string value, StringComparison comparison);
/* Line endings and indentation. */
public static string NormalizeLineEndings(this string s, string newline = "\n");
public static string IndentLines (this string s, string indent, bool indentEmptyLines = false);
/* Invariant formatting. */
public static string ToStringInvariant(object value);
public static string ToStringInvariant(int value);
public static string ToStringInvariant(long value);
public static string ToStringInvariant(float value);
public static string ToStringInvariant(double value);
public static string ToStringInvariant(decimal value);
/* Invariant parsing. */
public static bool TryParseInt (string s, out int value);
public static bool TryParseFloatInvariant(string s, out float value);
public static bool TryParseBool (string s, out bool value);
public static bool TryParseEnum<TEnum> (string s, bool ignoreCase, out TEnum value) where TEnum : struct, Enum;
/* Truncate and pad. */
public static string Truncate (this string s, int maxLength, string ellipsis = "...");
public static string SafePadLeft (this string s, int totalWidth, char paddingChar = ' ');
public static string SafePadRight (this string s, int totalWidth, char paddingChar = ' ');
public static string SafePadCenter(this string s, int totalWidth, char paddingChar = ' ');
/* Sanitization and JSON escape. */
public static string SanitizeForFileName(this string s, char replacement = '_');
public static string EscapeJSONString (string value);
/* Rich text (TextMeshPro tag helpers). */
public static string EscapeRichText (this string s);
public static string StripRichTextTags (this string s);
public static string Bold (string text);
public static string Italic (string text);
public static string Underline (string text);
public static string Strikethrough (string text);
public static string Size (string text, int size);
public static string Color (string text, Color32 color, bool includeAlpha = true);
}
}
namespace Scylla.Core.Util
{
public sealed class TabularText : IEquatable<TabularText>
{
/* Construction (constructor or fluent builder). */
public TabularText(int columnCount = 1, string[] header = null, bool sort = false,
int sortColumn = 0, bool hasFooter = false, int maxColumnLength = 0,
string divider = null, char fill = ' ', string rowLeading = null,
TextAlignment defaultAlignment = TextAlignment.Left);
public TabularText(string[] header, bool sort = false, int sortColumn = 0, ...);
public static Builder CreateBuilder();
/* Properties. */
public int ColumnCount { get; }
public int RowCount { get; }
public int Width { get; }
public bool IsEmpty { get; }
public bool HasHeader { get; }
public bool HasFooter { get; }
/* Mutation. */
public TabularText AddRow (params string[] row);
public TabularText AddRow (IReadOnlyList<string> row);
public TabularText SetColumnAlignment (int columnIndex, TextAlignment alignment);
public TabularText Clear ();
/* Rendering. */
public override string ToString();
public string ToString(StringBuilder builder);
public void WriteTo (StringBuilder builder);
/* Builder. */
public sealed class Builder
{
public Builder WithColumnCount (int count);
public Builder WithHeader (params string[] header);
public Builder WithSorting (int sortColumn = 0);
public Builder WithFooter ();
public Builder WithMaxColumnLength (int maxLength);
public Builder WithDivider (string divider);
public Builder WithFill (char fill);
public Builder WithRowLeading (string leading);
public Builder WithAlignment (TextAlignment alignment);
public Builder WithColumnAlignment (int columnIndex, TextAlignment alignment);
public TabularText Build();
}
}
}
namespace Scylla.Core
{
public enum TextAlignment
{
Left, Right, Center, Justified
}
}
See the API reference for full signatures, remarks, and exception contracts on every member.
Method reference
The table below groups every StringUtil method by purpose. Most methods are this string extension methods; the few plain statics are flagged.
| Group | Method | Notes |
|---|---|---|
| Hashing | GetStableHash32(s, ignoreCase = false) (static) | Deterministic FNV-1a 32-bit. Returns 0 for null/empty. Use for cache keys, content IDs, dictionary keys that survive serialization. |
| Null / whitespace | EmptyIfNull(), NullIfEmpty(), NullIfWhiteSpace(), TrimToEmpty(), TrimToNull(), HasText() | Extension methods on string. HasText is the affirmative non-null-and-non-whitespace check. |
| Ordinal comparison | EqualsOrdinal(a, b) (static), EqualsOrdinalIgnoreCase(a, b) (static) | Byte-level equality, never culture-aware. Safe under Turkish locale and other culture edge cases. |
| Ordinal search | StartsWith*, EndsWith*, IndexOf(value, comparison), ContainsOrdinal* | Extension methods. Bypass culture; ignore-case variants fold case via char.ToUpperInvariant. |
| Substring | SafeSubstring(startIndex, length), Left(count), Right(count) | Out-of-range inputs return the empty string instead of throwing. Left/Right clamp at the string length. |
| Replace / remove | Replace(old, new, comparison), Remove(value, comparison), CountOccurrences(value, comparison) | Comparison mode controls case folding. Remove is Replace(value, "", comparison). |
| Line endings | NormalizeLineEndings(newline = "\n"), IndentLines(indent, indentEmptyLines = false) | Convert any \r\n / \r to the target newline; prepend an indent to every line. |
| Invariant format | ToStringInvariant(int / long / float / double / decimal / object) (static) | Always uses . for decimal, ASCII signs, no thousands separator. Round-trips through TryParseFloatInvariant etc. |
| Invariant parse | TryParseInt(s, out) (static), TryParseFloatInvariant(s, out) (static), TryParseBool(s, out) (static), TryParseEnum<TEnum>(s, ignoreCase, out) (static) | All return bool; never throw on bad input. |
| Truncate / pad | Truncate(maxLength, ellipsis = "..."), SafePadLeft(width, fill), SafePadRight(width, fill), SafePadCenter(width, fill) | Bounds-safe; over-long inputs are returned unchanged, null inputs are padded as empty. |
| Sanitization | SanitizeForFileName(replacement = '_') | Cross-platform character union plus trailing-dot/space trim. Result is portable on every desktop and console target. |
| JSON escape | EscapeJSONString(value) (static) | Produces a literal that can be wrapped in quotes and parsed as a JSON string. |
| Rich text wrap | Bold, Italic, Underline, Strikethrough, Size, Color (static) | Wrap input in the corresponding TMP tags. Color accepts a Color32 and optionally drops the alpha byte for a shorter form. |
| Rich text escape | EscapeRichText(), StripRichTextTags() | Encode < and > as Unicode escapes so tags do not render; strip existing tags from a string for plain-text consumption. |
Best Practices
- Use
GetStableHash32for any hash that crosses a process boundary.string.GetHashCodeis randomized per process in .NET Core, so the same string produces a different hash every run. Use the stable variant for cache keys, save-file keys, dictionary keys persisted to disk, and content IDs. - Prefer ordinal comparison for any non-UI string operation. Save data, config keys, asset paths, protocol literals, file extensions: all ordinal by nature. The culture-sensitive default operators are the source of subtle bugs on non-English locales.
- Always format numbers via
ToStringInvariantwhen the result crosses a process boundary. JSON, config files, save files, and network packets all need the same"1.5", not"1.5"on English locales and"1,5"on German.string.Formatandvalue.ToString()use the current culture by default. - Always parse numbers via
TryParseFloatInvariant(or the typed equivalents) on input that crossed a process boundary. The same culture-sensitivity argument applies in reverse: the parsed value depends on the running machine’s locale otherwise. - Use
HasTextover!string.IsNullOrWhiteSpace. The extension method reads more naturally and treatsnulland whitespace-only strings the same way most gameplay code wants. - Use
Truncatefor display,SafeSubstringfor slicing.Truncateadds an ellipsis when the result is shorter than the input;SafeSubstringreturns the slice unchanged. Picking the right one keeps “displayed” and “stored” semantics distinct. - Use
SanitizeForFileNamebefore writing any string-derived path. User-supplied names, level names, save-slot names: all of them need to survive Windows’s stricter naming rules. The cross-platform union ensures portability without per-OS branching. - Use
EscapeRichTexton any user-typed string that ends up in a TextMeshPro field. Without escaping, a chat message containing<color=red>will render with that styling, which is a chat-styling exploit vector. Escape on display, store the raw text. - Use
StripRichTextTagsbefore measuring or comparing display text. A label that contains<b>hello</b>is 13 characters but displays as 5; comparison and length checks should operate on the stripped form. - Use
TabularTextfor any “n columns, m rows” debug output. Manual padding drifts as content changes;TabularTextrebuilds the layout per render. Prefer the builder over the constructor for anything more than a header. - Use
TabularText.WriteTo(StringBuilder)in hot paths. Allocating a fresh string per render is wasteful when the output ends up appended to a larger builder anyway. Pass the destination builder in and skip the intermediate allocation. - Use
IndentLinesfor multi-line log entries. The framework’s log receivers typically prepend a single timestamp and category prefix; anIndentLinescall before logging keeps multi-line payloads (stack traces, structured dumps) visually grouped under that prefix.
Pitfalls
Related
- Math Utils
- Serialization Utils
- API reference:
Scylla.Core.Util.StringUtil - API reference:
Scylla.Core.Util.TabularText - API reference:
Scylla.Core.TextAlignment