- I. Using RhythmBase
- II. Implementing a New Level Type
- Overview
- Step 1: Create the Project
- Step 2: Define the Event Type Enum
- Step 3: Define the Time Unit (TickTime)
- Step 4: Define the Event Interface and Base Class
- Step 5: Define Event Subclasses
- Step 6: Define the Level Model
- Step 7: Register AssemblyInfo
- Step 8: Create GlobalUsing
- Step 9: Implement Hand-Written Converters
- Handling Unhandled Properties
- Step 10: Implement Level Serialization Methods
- Implementation-Specific Notes
Namespaces follow the pattern RhythmBase.[GameType].[CategoryType].
- GameType: All components for a specific game; enum types also reside here.
Global: Shared components (used by all games).RhythmDoctor: Rhythm Doctor specific components.Adofai: A Dance of Fire and Ice specific components.BeatBlock: BeatBlock specific components.Rizline: Rizline specific components.
- CategoryType: Further classification of components.
Components: Core data models.Constants: Predefined constants.Converters: Serializers.Events: All event data models.Extensions: Extension methods.Utils: Utility tools.
All game types share the public interfaces (IEvent, ILevel, ITickTime, etc.) and shared components (Color, geometry types, EnumCollection, etc.) under RhythmBase.Global. Each game type implements its own event model, level model, and serializers.
The following examples use Rhythm Doctor; other game types have identical API signatures, differing only in class names and file extensions.
You can create an empty level, a default level (useful for testing), or deserialize directly from a JSON string or JsonDocument.
using Level emptyLevel = [];
using Level defaultLevel = Level.Default;
using Level jsonLevel = Level.FromJsonString(...);
using Level jsonDocumentLevel = Level.FromJsonDocument(...);Note: Multi-file formats (BeatBlock, Rizline) do not support JSON read/write, as their level data is spread across multiple files.
Supports reading from file paths, streams, or directories. All methods provide async overloads.
It is recommended to use
usingstatements to manage level variables, ensuring resources are released and temporary extracted files are cleaned up when leaving scope.
using RhythmBase.RhythmDoctor.Components;
LevelReadSettings settings = new()
{
ZipFileProcessMethod = ZipFileProcessMethod.AllFiles,
LoadAssets = true,
InactiveEventsHandling = InactiveEventsHandling.Store,
UnreadableEventsHandling = UnreadableEventHandling.Store,
};
// Read a level file
using Level rdlevel1 = Level.FromFile(@"your\level.rdlevel");
// Read a level archive
using Level rdlevel2 = Level.FromFile(@"your\level.rdzip");
// Read an archive with custom settings
using Level rdlevel3 = Level.FromFile(@"your\level.zip", settings);
// Read from a stream
using Stream fs = new FileStream(@"your\level.rdlevel", FileMode.Open, FileAccess.Read);
using Level rdlevel4 = Level.FromStream(fs, settings);
// View inactive events
foreach (var inactiveEvent in settings.InactiveEvents)
Console.WriteLine($"Inactive Event: {inactiveEvent}");
// View unreadable events
foreach (var unreadableEvent in settings.UnreadableEvents)
Console.WriteLine($"Unreadable Event: {unreadableEvent}");When reading archives, LevelReadSettings.ZipFileProcessMethod defaults to AllFiles, which extracts level resources to a temporary directory.
You can customize the temporary directory or clean up manually:
GlobalSettings.CachePath = "cache";
GlobalSettings.CacheDirectoryPrefix = "MyPrefix";
GlobalSettings.ClearCache();Supports saving to a file, stream, or packaging as an archive.
Can also serialize directly to a JSON string or JsonDocument (only for game types that support IJsonLevel).
rdlevel1.SaveToFile(@"your\output1.rdlevel");
rdlevel2.SaveToZip(@"your\output2.rdzip");
rdlevel3.SaveToStream(fs);
Console.WriteLine(rdlevel4.ToJsonString());
JsonDocument jsonDocument = rdlevel4.ToJsonDocument();LevelReadSettings and LevelWriteSettings each provide lifecycle events:
| Event | Trigger |
|---|---|
BeforeReading |
Before reading a level |
AfterReading |
After reading a level |
BeforeWriting |
Before writing a level |
AfterWriting |
After writing a level |
using RhythmBase.Global.Settings;
LevelWriteSettings settings = new();
settings.AfterWriting += (sender, e) => Console.WriteLine("Level saved!");
rdlevel.SaveToFile(@"your\outLevel.rdlevel", settings);The following types are located in the RhythmBase.Global.Components namespace and are shared across all game types.
Color type supporting ARGB component access and multiple string format conversions (RgbaHex, ArgbObject, etc.). Each game type specifies its default serialization format via JsonConverterLink in AssemblyInfo.cs.
Point, Size, Rect, RotatedRect and similar types are all planar geometry data types.
| Suffix | Meaning | Example |
|---|---|---|
| (none) | Nullable float | Point.X is float? |
I |
Nullable integer | PointI.X is int? |
N |
Non-nullable float | PointN.X is float |
NI |
Non-nullable integer | PointNI.X is int |
RotatedRect.Angleis always float, regardless of suffix rules.
Represents a time range, commonly used for event queries. Each game type has its own Range implementation (e.g., RhythmBase.RhythmDoctor.Components.Range) linked to its corresponding time unit.
using RhythmBase.RhythmDoctor.Components;
var result = rdlevel.InRange(new Range(rdlevel.DefaultBeat + 10, null));EnumCollection<TEnum> and ReadOnlyEnumCollection<TEnum> are high-performance enum value collections backed by bitmap storage.
EnumCollection<TEnum>: Mutable collection, supportsAddandRemove.ReadOnlyEnumCollection<TEnum>: Immutable collection, used for type classification and batch filtering.
Both support collection expression syntax:
using RhythmBase.Global.Components;
// Collection expression creation
ReadOnlyEnumCollection<EventType> types = [
EventType.AddClassicBeat,
EventType.AddFreeTimeBeat,
EventType.MoveRow];
// Mutable collection
EnumCollection<EventType> mutable = [EventType.Tint, EventType.Comment];
mutable.Add(EventType.MoveRow);
// Set operations
ReadOnlyEnumCollection<EventType> a = [EventType.Tint, EventType.Comment];
ReadOnlyEnumCollection<EventType> b = [EventType.Comment, EventType.MoveRow];
var intersect = a.Intersect(b); // [Comment]
var union = a.Union(b); // [Tint, Comment, MoveRow]
var except = a.Except(b); // [Tint]
var symExcept = a.SymmetricExcept(b); // [Tint, MoveRow]
// Membership checks
bool hasTint = a.Contains(EventType.Tint); // true
bool hasAny = a.ContainsAny(b); // true
bool hasAll = a.ContainsAll([EventType.Tint]); // trueEnumCollection<TEnum> can be converted to a read-only collection via AsReadOnly().
Each game type has its own time units, expressions, rooms, etc. The following uses Rhythm Doctor as an example.
Each game type implements the ITickTime<TickTime> interface, representing a point on the level timeline. Rhythm Doctor's implementation is the TickTime struct, caching the following read-only information:
BeatOnly:float, total beat count from the start of the level (starting from 1).Bar/Beat:int/float, current bar and beat position, obtained via deconstruction:(int bar, float beat) = someBeat;
TimeSpan:TimeSpan, the current moment.Bpm:float, current BPM.Cpb:int, current crotchets per bar.
TickTime maintains association with the level when possible, preferring to derive other time units from BeatOnly.
When unassociated, it uses cached values for calculations.
Level level = [];
// === Associated with level ===
TickTime beat1 = new(level.Calculator, 20);
TickTime beat2 = new(level.Calculator, 3, 5);
TickTime beat3 = new(level.Calculator, TimeSpan.FromSeconds(15));
TickTime beat4 = level.Calculator.BeatOf(20);
TickTime beat5 = level.Calculator.BeatOf(3, 5);
TickTime beat6 = level.Calculator.BeatOf(TimeSpan.FromSeconds(15));
// Level default beat
TickTime beat7 = level.DefaultBeat;
// Link an existing beat to a specific level
TickTime beat8 = beat1.WithLink(level);
TickTime beat9 = beat2.WithLinkIfNull(level);
// === Not associated with level ===
TickTime beat10 = new(20);
TickTime beat11 = new(3, 5);
TickTime beat12 = new(TimeSpan.FromSeconds(15));
// Tuple implicit conversion
TickTime beat13 = (3, 5);
// Break association
TickTime beat14 = beat1.WithoutLink();
// === Check association state ===
bool isLinked = !beat13.IsEmpty;When an event is added to a level, its time unit automatically associates; when removed, it automatically disassociates.
Two associated time units must point to the same level when used in operations.
using RhythmBase.RhythmDoctor.Components;
TickTime beat1 = level.Calculator.BeatOf(1);
TickTime beat2 = beat1.WithoutLink();
Console.WriteLine(beat1.FromSameLevel(beat2)); // False
Console.WriteLine(beat1.FromSameLevelOrNull(beat2)); // TrueRhythm Doctor specific, used to store expression strings with basic arithmetic support (parsing and evaluation not yet complete).
Uses string concatenation internally, so multiple operations may produce nested parentheses.
using RhythmBase.RhythmDoctor.Components;
Expression exp1 = new("i2+1");
Expression exp2 = new(30);
Expression exp3 = new("25.5");
Expression result = exp1 - exp2 * exp3;
Console.WriteLine(result); // i2+1-765Order order = [2, 0, 3, 1];
Room room = [2, 3];
RDCharacter c1 = RDCharacters.Samurai;
RDCharacter c2 = "custom_character.png";
RoomHeight roomHeight = (20, 30, 10, 40);All game types implement events via the IEvent<TType, TBeat> interface, where TType is the event enum type and TBeat is the time unit type. Public interfaces are in RhythmBase.Global.Events:
classDiagram
direction LR
class IEvent {
<<interface>>
}
class IDurationEvent {
<<interface>>
+float Duration
}
class IEaseEvent {
<<interface>>
+EaseType Ease
}
class IFileEvent {
<<interface>>
+IEnumerable~FileReference~ Files
}
class IAudioFileEvent {
<<interface>>
+IEnumerable~FileReference~ AudioFiles
}
class IImageFileEvent {
<<interface>>
+IEnumerable~FileReference~ ImageFiles
}
class IForwardEvent {
<<interface>>
+string ActualType
}
IEvent <|.. IDurationEvent
IEvent <|.. IFileEvent
IDurationEvent <|.. IEaseEvent
IFileEvent <|.. IAudioFileEvent
IFileEvent <|.. IImageFileEvent
Each game type defines its own event interface (e.g., IBaseEvent), base classes (e.g., BaseEvent, BaseRowAction), and concrete event classes on top of these.
You can browse or filter event types by class diagram. All events are record types, supporting with expression for copying instances.
Level inherits from OrderedEventCollection, internally using a red-black tree sorted by time.
Extension methods allow fast filtering by type, interface, time range, or custom conditions.
using RhythmBase.RhythmDoctor.Extensions;
using RhythmBase.RhythmDoctor.Components;
// Filter by type
var moves = rdlevel.OfEvent<MoveRow>();
// Filter by time range
var inRange = rdlevel.InRange(level.Calculator.BeatOf(3), level.Calculator.BeatOf(5));
// Filter by exact time
var atBeat = rdlevel.AtBeat(level.Calculator.BeatOf(2, 1));
// Combined conditions
var list = rdlevel.OfEvent<MoveRow>()
.Where(i => 0 <= i.Y && i.Y < 3)
.InRange(level.Calculator.BeatOf(3), level.Calculator.BeatOf(5));In Rhythm Doctor, Row and Decoration also hold event collections, so the above extension methods work on tracks and sprites too.
var list = rdlevel.Decorations[0]
.OfEvent<Tint>()
.InRange(new TickTime(11, 1), new TickTime(13, 1));Event navigation methods are also available for locating adjacent events in ordered collections:
var prev = someEvent.Before<MoveRow>();
var next = someEvent.Next<MoveRow>();
var front = someEvent.Front();When creating events, the time unit parameter can be unassociated with a level; once added to a level, association is automatic, and disassociation occurs on removal.
using RhythmBase.RhythmDoctor.Components;
using RhythmBase.RhythmDoctor.Events;
Comment comment = new() { Beat = new(12), Text = "My_comment." };
Console.WriteLine(comment); // [11,?,?] Comment My_comment.
rdlevel.Add(comment);
Console.WriteLine(comment); // [2,4] Comment My_comment.
rdlevel.Remove(comment);
Console.WriteLine(comment); // [11,?,?] Comment My_comment.In Rhythm Doctor, adding, modifying, or removing SetCrotchetsPerBar events automatically updates the timeline.
Track and sprite events must be added via Add() on the corresponding track or sprite; removal can be called at any level (level, track, or sprite).
If built-in event types are insufficient, you can inherit from ForwardEvent (or ForwardRowEvent, ForwardDecorationEvent).
Unknown event types encountered during level loading are automatically deserialized as the corresponding ForwardEvent.
Every event provides an indexer this[string propertyName] for direct JSON property access:
using RhythmBase.RhythmDoctor.Events;
public class MyEvent : ForwardEvent
{
public string MyProperty
{
get => this["myProperty"].GetString() ?? "";
set => this["myProperty"] = JsonDocument.Parse($"\"{value}\"").RootElement;
}
public MyEvent()
{
ActualType = nameof(MyEvent);
}
}Custom events can be read and written like normal events.
Note that Type remains EventType.ForwardEvent; ActualType holds the custom type name.
MyEvent myEvent = new();
rdlevel.Add(myEvent);
myEvent.Beat = new(8);
Console.WriteLine(myEvent.Type); // ForwardEvent
Console.WriteLine(myEvent.ActualType); // MyEventWhen an undefined event type is encountered during level reading, it is converted to
ForwardEvent,ForwardDecorationEvent, orForwardRowEventbased on field characteristics. Events with atargetfield becomeForwardDecorationEvent, those with arowfield becomeForwardRowEvent, others becomeForwardEvent.
If existing events lack properties, you can use the indexer to get or set property values directly.
You can also override existing events to create a supplemented event model.
Comment comment1 = new Comment() { ["extraText"] = JsonElement.Parse("\"hello\"") };
MyComment comment2 = new MyComment() { ExtraText = "hello" };
record MyComment: Comment
{
public string ExtraText
{
get => this["extraText"].GetString() ?? "";
set => this["extraText"] = JsonElement.Parse($"\"{value}\"");
}
}The source generator automatically produces EnumConverterExtensions for each game type, providing conversion methods between enums and types. Each game type's EventTypeRegistry provides type classification queries.
using RhythmBase.RhythmDoctor.Components;
using RhythmBase.RhythmDoctor.Events;
using RhythmBase.RhythmDoctor.Converters;
Console.WriteLine(EventType.Tint.ToEnumString()); // "Tint"
Console.WriteLine("Tint".TryParseEventType(out var t)); // true, t = EventType.Tint
// EventTypeRegistry classification queries
var decorationTypes = EventTypeRegistry.ToEnums<BaseDecorationAction>();
var rowTypes = EventTypeRegistry.ToEnums<BaseRowAction>();RichText is in the RhythmBase.Global.Components.RichText namespace, supporting styled text fragment combination via the + operator, with serialization/deserialization capabilities.
RichLine<TStyle>: A complete rich text line.Phrase<TStyle>: A single styled fragment.IRichStringStyle<TStyle>: Style rule interface.
All can be implicitly converted from string (becoming unstyled text).
using RhythmBase.Global.Components.RichText;
RichLine<RichStringStyle> line = RichLine<RichStringStyle>.Deserialize("Hel<color=#00FF00>lo");
Console.WriteLine(line.ToString()); // Hello
Console.WriteLine(line.Serialize()); // Hel<color=lime>lo</color>
line += new Phrase<RichStringStyle>(" Rhythm") { Style = new() { Color = Color.Lime } };
line += " Doctor!";
Console.WriteLine(line.ToString()); // Hello Rhythm Doctor!
Console.WriteLine(line.Serialize()); // Hel<color=lime>lo Rhythm</color> Doctor!Supports accessing and modifying fragments via indexing:
RichLine<RichStringStyle> line = RichLine<RichStringStyle>.Deserialize("Hel<color=#00FF00>lo Rhythm</color> Doctor!");
Console.WriteLine(line[6..].ToString()); // Rhythm Doctor!
Console.WriteLine(line[6..].Serialize()); // <color=lime>Rhythm</color> Doctor!
line[5] = " and Welcome to ";
Console.WriteLine(line.ToString()); // Hello and Welcome to Rhythm Doctor!
Console.WriteLine(line.Serialize()); // Hel<color=lime>lo</color> and Welcome to <color=lime>Rhythm</color> Doctor!Dialogue format components are also provided for modular dialogue text construction:
using RhythmBase.Global.Components.RichText;
DialogueExchange exchange =
[
new DialogueBlock
{
Character = "Paige",
Expression = "neutral",
Content = RichLine<DialoguePhraseStyle>.Deserialize("Hel<color=#00FF00>lo [2]<shake>Rhythm</color> Doctor</shake>!"),
},
new DialogueBlock
{
Character = "Ian",
Content = "Hello Paige!",
},
new DialogueBlock
{
Character = "Paige",
Expression = "happy",
Content = new Phrase<DialoguePhraseStyle>("What a good day!")
{
Events =
[
new DialogueTone(DialogueToneType.VerySlow, 6),
new DialogueTone(DialogueToneType.Static, 11),
],
Style = new DialoguePhraseStyle
{
Volume = 0.5f,
Bold = true,
},
}
}
];
Console.WriteLine(exchange.Serialize());
// Paige_neutral:Hel<color=lime>lo [2]<shake>Rhythm</color> Doctor</shake>!
// Ian:Hello Paige!
// Paige_happy:<volume=0.5><bold>What a[vslow] good[static] day!</volume></bold>After importing RhythmBase.Global.Components.Easing, you can use the EaseType enum directly and compute easing values via the Calculate() extension method.
using RhythmBase.Global.Components.Easing;
double var1 = EaseType.InSine.Calculate(0.25);
double var2 = EaseType.Linear.Calculate(0.5, 4, 9);
Console.WriteLine(var1); // 0.07612046748871326
Console.WriteLine(var2); // 6.5Automatically created with the Level, accessed via Level.Calculator.
Used to construct associated TickTime instances, convert between various time units on the level timeline, and query BPM and CPB at any moment.
Level level = [];
BeatCalculator calculator = level.Calculator;
Console.WriteLine(calculator.BarBeatToBeatOnly(3, 1));
Console.WriteLine(calculator.BarBeatToTimeSpan(3, 1));
Console.WriteLine(calculator.BeatOnlyToBarBeat(3));
Console.WriteLine(calculator.BeatOnlyToTimeSpan(3));
Console.WriteLine(calculator.TimeSpanToBarBeat(TimeSpan.FromSeconds(3)));
Console.WriteLine(calculator.TimeSpanToBeatOnly(TimeSpan.FromSeconds(3)));
Console.WriteLine(calculator.BeatsPerMinuteOf((3, 1)));
Console.WriteLine(calculator.CrotchetsPerBarOf((3, 1)));You can manually refresh the internal cache via BeatCalculator.Refresh().
Provides a TryRun() method for evaluating Rhythm Doctor expressions.
using RhythmBase.RhythmDoctor.Components.RDLang;
RDLang.Variables.i[1] = 9;
RDLang.TryRun("numMistakesP2 = 3", out float result); // 3
RDLang.TryRun("numMistakesP2+i1", out result); // 12
RDLang.TryRun("atLeastRank(A)", out result); // 1Created with ADLevel, accessed via ADLevel.Calculator.
using RhythmBase.RhythmDoctor.Components;
using RhythmBase.RhythmDoctor.Events;
using RhythmBase.RhythmDoctor.Extensions;
// Load VFX level
using Level vfxLevel = Level.FromFile(@"vfx.rdlevel");
// Load beatmap level
using Level audioLevel = Level.FromFile(@"beat.rdlevel");
// Remove all tracks from VFX level
foreach (var row in vfxLevel.Rows.ToList())
vfxLevel.Rows.Remove(row);
// Copy tracks from beatmap level to VFX level
foreach (var row in audioLevel.Rows)
{
Row row2 = new()
{
Rooms = row.Rooms,
Character = row.Character,
Sound = row.Sound,
RowType = row.RowType
};
vfxLevel.Rows.Add(row2);
foreach (var evt in row.OfEvent<BaseBeat>())
row2.Add(evt);
}
// Copy non-track events from sound bar
foreach (var sound in audioLevel.Where(e =>
e.Tab == Tabs.Sounds &&
e is not BaseRowAction &&
e is not PlaySong &&
e is not SetCrotchetsPerBar))
{
vfxLevel.Add(sound);
}
// Save result
vfxLevel.SaveToFile(@"result.rdlevel");The process of adapting a new game can be summarized as:
Define Enum → Define TickTime → Define Event Interface/Base Class → Define Event Subclasses
→ Define Level → Register AssemblyInfo → Implement Hand-Written Converters → Implement Serialization Methods
The source generator automatically produces the following based on declarations in AssemblyInfo.cs:
- Property-level converters for each event class (
MemberConverter<T>) - Bidirectional type-enum mappings (
EventTypeRegistry) - Converter routing table (
EventConverterMap) - String conversion extension methods for enums (
TryParse/ToEnumString)
The following uses MyGame as a hypothetical game type name. The four completed implementations (RhythmDoctor, Adofai, BeatBlock, Rizline) serve as practical references.
Create a .NET class library project and reference the RhythmBase NuGet package:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;netstandard2.0</TargetFrameworks>
<RootNamespace>RhythmBase</RootNamespace>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RhythmBase" Version="*" />
</ItemGroup>
</Project>
RootNamespacemust be set toRhythmBaseto ensure the source generator places generated code in the correctRhythmBase.{GameType}.Convertersnamespace.
Create Enums.cs and mark it with [JsonEnumSerializable]:
namespace RhythmBase.MyGame;
[JsonEnumSerializable]
public enum EventType
{
Note,
Drag,
// ... all event types
ForwardEvent, // Fallback compatibility type (optional)
}Rules:
- Enum member names must exactly match event class names
- Fallback compatibility types are fixed:
ForwardEvent,ForwardRowEvent,ForwardDecorationEvent - Must be marked with
[JsonEnumSerializable]
Implementation differences:
| Implementation | Difference | Syntax |
|---|---|---|
| RhythmDoctor | Default PascalCase | [JsonEnumSerializable] |
| BeatBlock | camelCase serialization | [JsonEnumSerializable(false)] |
| Rizline | Numeric member names, serialized as numbers | Member names like _0, _1 |
| Adofai | Multiple enums coexist | Register EventType + FilterType separately |
Create a struct implementing ITickTime<TickTime>, representing a point on the level timeline:
public struct TickTime : ITickTime<TickTime>
{
public TimeSpan TimeSpan { get; }
public float Tick { get; }
// ... comparison operators, constructors, etc.
}Key design points:
- Support construction from
float(beat),(bar, beat)tuple,TimeSpan, etc. - Associate/disassociate with
BeatCalculator(lazy computation + caching) - Support tuple implicit conversion:
(int bar, float beat) => TickTime - Comparison operators (
>,<,>=,<=,==,!=)
Reference implementation: RhythmBase.RhythmDoctor/RhythmDoctor/Components/TickTime.cs
Event interface (namespace-scoped):
public interface IBaseEvent : IEvent<EventType, TickTime>
{
bool Active { get; set; }
new TickTime TickTime { get; set; }
// ... game-specific common properties
JsonElement this[string propertyName] { get; set; }
}Event base class:
public abstract record class BaseEvent : IBaseEvent
{
public abstract EventType Type { get; }
public virtual TickTime TickTime { get; set; }
public bool Active { get; set; } = true;
internal Dictionary<string, JsonElement> _extraData = [];
public JsonElement this[string propertyName]
{
get => _extraData.TryGetValue(propertyName, out var v) ? v : default;
set => _extraData[propertyName] = value;
}
}The _extraData dictionary stores unknown properties, ensuring lossless round-tripping.
Typical inheritance tree (choose based on game characteristics):
BaseEvent (abstract)
├── BaseRowAction (abstract) # Row event base, with "row" field
│ ├── BaseBeat (abstract) # Beat event base
│ └── ...
├── BaseDecorationAction (abstract) # Decoration event base, with "target" field
├── BaseBeatsPerMinute (abstract) # BPM event base
└── ...
Not all games need the row/decoration distinction. Adofai's event tree uses BaseTileEvent as the main branch; BeatBlock and Rizline have flatter event trees.
Each event class is marked with [JsonObjectSerializable]:
[JsonObjectSerializable]
public record class Note : BaseEvent
{
public override EventType Type { get; } = EventType.Note;
// ... event-specific properties
}Property attributes:
| Attribute | Purpose |
|---|---|
[JsonObjectSerializable] |
Auto-generate serializer |
[JsonObjectHasSerializer(typeof(C))] |
Has custom serializer, still needs mapping |
[JsonObjectNotSerializable] |
No serializer needed (e.g., ForwardEvent) |
[JsonObjectSerializationFallback] |
Fallback model for unknown types (globally unique) |
[JsonAlias("name")] |
Alias used in JSON |
[JsonIgnore] |
Ignored during serialization |
[JsonCondition("$&.Prop != value")] |
Conditional write |
[JsonTime(JsonTimeType.Milliseconds)] |
TimeSpan serialized as milliseconds/seconds |
[JsonConverter(typeof(C))] |
Use specified converter |
public partial class Level :
OrderedEventCollection<IBaseEvent, EventType, TickTime>,
IArchiveLevel<Level, IBaseEvent, EventType, TickTime>,
// Choose interfaces based on format
IChart<TickTime>
{
// ... game-specific components (Settings, Rows, etc.)
protected override TickTime CreateInstance(float beat) => new TickTime(beat);
protected override ReadOnlyEnumCollection<EventType> Types => EventTypeRegistry.Types;
protected override ReadOnlyEnumCollection<EventType> TypesOf<TTarget>() => EventTypeRegistry.ToEnums(typeof(TTarget));
}Level format selection:
| Interface | Suitable format | Existing implementations |
|---|---|---|
ISingleFileLevel |
Single file | RhythmDoctor (.rdlevel), Adofai (.adofai) |
IArchiveLevel |
Archive | All four |
IJsonLevel |
JSON fully representable | RhythmDoctor, Adofai |
IMultiFileLevel |
Multi-file directory | BeatBlock, Rizline |
Multi-file formats (BeatBlock, Rizline) do not implement IJsonLevel because JSON strings cannot fully represent level data distributed across multiple files.
Create AssemblyInfo.cs in the project root:
[assembly: RhythmBase.JsonConverterId(nameof(RhythmBase.MyGame))]
[assembly: RhythmBase.JsonConverterSourceType(
typeof(IBaseEvent), // Event interface
typeof(RhythmBase.MyGame.EventType), // Event enum
typeof(RhythmBase.MyGame.Converters.MemberConverter<>), // Converter base class
nameof(IBaseEvent.Type) // Enum property name
)]
// Link custom converters for shared types (as needed)
[assembly: RhythmBase.JsonConverterLink(typeof(Color), typeof(ColorConverter.RgbaHex))]
[assembly: RhythmBase.JsonConverterLink(typeof(RichLine<RichStringStyle>), typeof(RichTextConverter<RichStringStyle>))]JsonConverterLink differences across implementations:
| Implementation | Color format |
|---|---|
| RhythmDoctor | ColorConverter.RgbaHex |
| Adofai | ColorConverter.RgbaHex |
| BeatBlock | ColorConverter.RgbObject |
| Rizline | ColorConverter.ArgbObject |
Multi-target registration (e.g., Adofai registers both events and filters):
[assembly: RhythmBase.JsonConverterSourceType(typeof(IBaseEvent), typeof(EventType), typeof(MemberConverter<>), nameof(IBaseEvent.Type))]
[assembly: RhythmBase.JsonConverterSourceType(typeof(IFilter), typeof(FilterType), typeof(FilterMemberConverter<>), nameof(IFilter.Type))]Create GlobalUsing.cs in the project root:
global using RhythmBase.Global.Components;
global using RhythmBase.Global.Events;
global using RhythmBase.Global.Exceptions;
global using RhythmBase.Global.Extensions;
global using RhythmBase.Global.Settings;
global using RhythmBase.Global.Converters.JsonSerialization;
global using RhythmBase.Global.Utils;
global using static RhythmBase.Global.Constants.Constants;
global using static RhythmBase.Global.Converters.EnumConverterExtensions;
global using static RhythmBase.MyGame.Converters.EnumConverterExtensions;The source generator auto-generates event property-level converters, but the following compound types need hand-written converters:
- LevelConverter: Reads/writes the entire level
- SettingsConverter: Reads/writes level settings
- BaseEventConverter: Event type routing (dispatches to
ConverterMapbased on thetypefield)
All hand-written converters inherit from MetadataJsonConverter<T>, whose Read/Write accept MetadataJsonSerializerOptions (serialization options with attached metadata).
Converter hierarchy:
JsonConverter<T> — .NET framework, handles arbitrary type JSON serialization
└── MetadataJsonConverter<T> — RhythmBase, adds metadata awareness
├── LevelConverter — reads/writes entire level
├── SettingsConverter — reads/writes settings
├── BaseEventConverter — event routing
└── ...
MemberConverter<T> — RhythmBase, reads/writes event properties field by field
├── BaseRowAction<T> — + "row"
├── BaseDecorationAction<T> — + "target"
└── Concrete event converter — generated by source generator
Division of labor: MetadataJsonConverter manages the { } boundary; MemberConverter manages the fields inside { }.
During deserialization, the converter system automatically maps JSON properties to event model fields. When a property is not recognized by the converter, it falls back to storing the value in the event's _extraData dictionary (accessible via the indexer event["propertyName"]).
For more control over this behavior, RhythmBase provides a two-level handler system:
- Developer level (
UnhandledFieldRegistry): Registered at startup, handles all deserialization operations. - User level (
LevelReadSettings.RegisterHandler): Registered per read operation, runs after developer handlers.
Both levels use the same delegate type UnhandledPropertyHandler<T> and support interface-based dispatch.
Handlers registered here are global and apply to all level reads.
Concrete type registration (matches only the exact type):
UnhandledFieldRegistry.Register<PlaySong>("customVolume", (ref PlaySong e, JsonElement value) =>
{
e.Volume = value.GetSingle();
return true; // handled
});
// Ignore a specific field silently
UnhandledFieldRegistry.Ignore<SetClapSounds>("legacyField");Interface-based registration (matches all concrete types implementing the interface):
The source generator produces RegisterForXXX methods for each interface found in the event type hierarchy. Each method internally registers a wrapped handler for every concrete type, using Unsafe.As to convert ref ConcreteType to ref InterfaceType — no boxing, no allocation.
// Generated method: covers TintRows, Tint, PaintHands, etc.
UnhandledFieldHelper.RegisterForITintEvent("borderOpacity", (ref ITintEvent e, JsonElement value) =>
{
if (!value.TryGetInt32(out int alpha)) return false;
var c = e.BorderColor.Color;
c.A = (byte)(alpha / 100f * 255);
e.BorderColor = c;
return true;
});
// Ignore for all types implementing the interface
UnhandledFieldHelper.RegisterForITintEvent("legacyOpacity", (ref ITintEvent _, JsonElement __) => true);Handlers registered here are per-operation and run after developer handlers.
var settings = new LevelReadSettings();
settings.RegisterHandler<PlaySong>("mod_customVolume", (ref PlaySong e, JsonElement value) =>
{
e.Volume = value.GetSingle();
return true;
});Interface-based registration is also supported:
settings.RegisterHandler<ITintEvent>("mod_customTint", (ref ITintEvent e, JsonElement value) =>
{
e.TintColor = new PaletteColorWithAlpha(value.GetString());
return true;
});Both levels use enum-based matching via EventTypeRegistry. The registered type is converted to a ReadOnlyEnumCollection<EventType> at registration time. At dispatch time, the event's Type property is checked against this collection using O(1) bit operations. This means interface-based handlers only fire for events whose EventType belongs to the registered set — no redundant checks.
| Feature | Developer (UnhandledFieldRegistry) |
User (LevelReadSettings) |
|---|---|---|
| Scope | Global, all reads | Per-operation |
| Registration | Register<T> / Ignore<T> / RegisterForXXX |
RegisterHandler<T> |
| Interface support | Via source-generated RegisterForXXX |
Built-in, AOT-compatible |
| Matching | Enum-based, O(1) | Enum-based, O(1) |
| Fallback | _extraData dictionary |
_extraData dictionary |
Implement read/write methods in Level.SerializeMethods.cs (partial class). Core call chain:
// Reading
Level? level = FileMainEntryConverter.DeserializeMainEntry<Level>(
new StreamDataSource(rdlevelStream), options);
// Writing
FileMainEntryConverter.SerializeMainEntry(this, stream, options);ZIP format uniformly uses the "extract to temp directory → call FromDirectory" pattern:
public static async Task<Level> FromZipAsync(string filepath, LevelReadSettings? settings = null, ...)
{
DirectoryInfo tempDirectory = new(Path.Combine(
GlobalSettings.CachePath, GlobalSettings.CacheDirectoryPrefix + Path.GetRandomFileName()));
ZipFile.ExtractToDirectory(stream, tempDirectory.FullName, overwriteFiles: true);
Level level = await FromDirectoryAsync(tempDirectory.FullName, settings, cancellationToken);
level.ResolvedPath = Path.GetFullPath(filepath);
level.Filepath = Path.GetFullPath(filepath);
return level;
}Multi-file formats also need FromDirectoryAsync / SaveToDirectoryAsync to read/write sub-files by filename convention.
Filepath / ResolvedPath / ResolvedDirectory properties: Multi-file formats need internal set to allow assignment in FromZip / FromDirectory.
- Single file format (
.rdlevel), fully supportsIJsonLevel - Events organized by Row and Decoration
- Has
BeatCalculatorfor beat ↔ time conversion - Color uses
RgbaHexformat - Reference implementation — consult this project first when adapting new games
- Supports multiple
JsonConverterSourceType: event system and filter system registered separately - Filter types use structs (
struct BlurRegular : IFilter), not classes - Color uses
RgbaHexformat - Multiple enums defined in one project (
EventType+FilterType)
- Enum uses camelCase:
[JsonEnumSerializable(false)] - Multi-file format:
manifest.json(main) +level.json+chart-*.json+tags/ - Does not implement
IJsonLevel - Level implements
IDisposable, requires manual resource management - Color uses
RgbObjectformat - Has
versionfield, level has multiple version formats Filepath/ResolvedPath/ResolvedDirectoryproperties needinternal set
- Enum members are numeric: e.g.,
EventType._0, serialized as"0" - Multi-file format:
metadata.json+chart*.json - Does not implement
IJsonLevel - Color uses
ArgbObjectformat Filepath/ResolvedPath/ResolvedDirectoryproperties needinternal set
- All events are
recordtypes, supportingwithexpressions _extraDatadictionary stores unknown properties, ensuring lossless round-tripping- The source generator handles most serialization code; hand-written converters are only for complex logic
ConverterMap+ConverterHubform the complete type routing and serializer registration systemEventTypeRegistryprovides bidirectional type-enum queries and classificationForwardEventmechanism ensures backward compatibility for unknown event typesPath.GetRelativePathis unavailable on .NET Standard 2.0; usefile.Substring(dir.Length + 1)instead- Multi-file format
FromZipneeds to setisZip/isExtractedstatus fields