Creating a mod
Signals and network
Compose several models, resolve their neighbours and handle unknown observations.
Organise a model’s responsibilities
After the first mod, you can add several signal families to one package. Each signalModel owns its identity, enums and callbacks. The signalMod entry point assembles these declarations; it should not become one large rule comparing every type.
| Responsibility | What you write |
|---|---|
| Declaration | Stable identity, catalogue, fallbacks and function registration. |
| Indications | Two enums: the displayed aspect and the reason explaining the decision. |
| Settings | Named checkboxes and integers, initial values and panel help. |
| Rules | A decision from the observation and, when needed, the downstream neighbour. |
| Appearance | Catalogue images and optional animation. |
| Driving | An explicitly chosen rule and speeds for each indication. |
| Diagnostics | Readable reason names and decisions classified as faults. |
Keep small models in one file. As they grow, group files by family; place genuinely shared rules in a common directory named for their domain. Rule functions work on supplied values without opening a game connection or starting timers.
Two models with independent vocabularies
package wiki.models
import nimby.*
// Two independent vocabularies, even though both sets of ordinals start at zero.
enum class MainAspect { Closed, Open }
enum class MainReason { Unknown, Clear }
enum class DistantAspect { Wait, Proceed }
enum class DistantReason { Unknown, MainClosed, MainOpen }
val mainSignal = signalModel(
"mon-reseau.principal", "Principal", "textures_principal",
fallback = Indication(MainAspect.Closed, MainReason.Unknown)
) {
rules {
if (fresh && routeKnown && block == Occupancy.Clear &&
settingsStatus != SettingsStatus.Unavailable &&
!observation.forcedStop && !observation.lampFailed)
Indication(MainAspect.Open, MainReason.Clear)
else Indication(MainAspect.Closed, MainReason.Unknown)
}
images { if (it.aspect == MainAspect.Open) "main-open.svg" else "main-closed.svg" }
}
val distantSignal = signalModel(
"mon-reseau.annonce", "Annonce", "textures_annonce",
fallback = Indication(DistantAspect.Wait, DistantReason.Unknown)
) {
rules {
when {
!fresh || !routeKnown || block != Occupancy.Clear ||
settingsStatus == SettingsStatus.Unavailable ||
observation.forcedStop || observation.lampFailed ->
Indication(DistantAspect.Wait, DistantReason.Unknown)
next == null -> null // Ask the SDK to resolve the neighbour.
else -> when (next?.of(mainSignal)?.aspect) {
MainAspect.Closed -> Indication(DistantAspect.Wait, DistantReason.MainClosed)
MainAspect.Open -> Indication(DistantAspect.Proceed, DistantReason.MainOpen)
null -> Indication(DistantAspect.Wait, DistantReason.Unknown)
}
}
}
images { if (it.aspect == DistantAspect.Proceed) "distant-proceed.svg" else "distant-wait.svg" }
}
// Composition and reading example: no driving instruction is declared.
fun createNetworkMod() = signalMod("mon-reseau", "My network") {
signal(mainSignal)
signal(distantSignal)
}
The main model decides locally. The distant model first checks its own block, then interprets the main model with next.of(mainSignal). These names and rules are fictional: the SDK supplies no national railway convention.
| Identity | Scope |
|---|---|
| ModInfo.id | The package: used for installation and optional services. |
| SignalType.id | The constructible model: its rules, settings and catalogue. |
| Signal.id | A placed instance in the observed game; do not reuse it in another game. |
A mod declares 1 through 16 models with distinct identities and catalogues. Reuse the same SignalModel instance in signal(model) and next.of(model): matching names or ordinals do not make two declarations interchangeable.
Resolve a neighbour only when needed
- The first rules call receives next == null. Returning an indication immediately settles this signal.
- Returning null asks the SDK to resolve the nextSignal link. The rule is then called again with the resolved neighbour.
- If the link is missing, the dependency cycles without a local decision, or the rule still does not conclude, the model uses invalidNetwork.
Distinguish next == null from next.of(mainSignal) == null. The first means resolution has not supplied a neighbour yet. The second can mean a resolved neighbour belongs to another model: choose an explicit policy for that case. The example above returns its fallback for that unknown model.
| Neighbour property | Use |
|---|---|
| id / type | Identify the downstream signal and its model in this observation. |
| of(model) | Read the recognised model’s exact enums without numeric conversion. |
| drivingRule | Read the declared rule if your rule knows how to interpret it; null remains a missing rule. |
| active | Read the neighbour’s activeWhen result; this proves neither freshness nor a clear track. |
next follows your mod’s observed network. It neither searches all nearby signals nor automatically reads other mods’ private decisions. evaluateNetwork tests the same mechanism outside the game; its 4096-signal limit per call is not a maximum map-size promise.
Make unknown information an explicit result
| Input | Meaning to preserve |
|---|---|
| fresh == false | Values do not establish the current state. Choose the model’s fallback. |
| routeKnown == false | The required route is not established; do not infer a clear block. |
| Occupancy.Unknown | Neither Clear nor Occupied is established. Do not substitute Clear. |
| SettingsStatus.Unavailable | The profile cannot be read; SignalRuleContext marks the observation stale. |
| forcedStop / lampFailed | Data for your rule to interpret; their names do not make a decision on your behalf. |
fallback is used notably when isolated calculation does not conclude; invalidNetwork covers an unresolved downstream dependency. These declarations do not replace rules branches for fresh, routeKnown and Occupancy.Unknown. Choose a distinct reason when it helps explain the result, then test unknown inputs before favourable cases.
Observe an approach across several blocks
package wiki.approach
import nimby.*
enum class ApproachAspect { Closed, Open }
enum class ApproachReason { Unknown, TrainApproaching }
val approachSignal = signalModel(
"monmod.approche", "Approach-activated signal", "textures_approche",
fallback = Indication(ApproachAspect.Closed, ApproachReason.Unknown)
) {
construction(states = listOf("closed.svg", "open.svg"))
observeApproach(blocks = 2)
rules {
if (fresh && routeKnown && block == Occupancy.Clear && trainApproaching &&
!observation.forcedStop && !observation.lampFailed)
Indication(ApproachAspect.Open, ApproachReason.TrainApproaching)
else Indication(ApproachAspect.Closed, ApproachReason.Unknown)
}
images { if (it.aspect == ApproachAspect.Open) "open.svg" else "closed.svg" }
// The mod chooses its driving policy here.
driving { if (it.aspect == ApproachAspect.Open) AutomaticDriving.clear() else AutomaticDriving.stop() }
}
In a project prepared with the first tutorial, add this file and assemble approachSignal from createMod. The catalogue declares closed.svg and open.svg: add these files to assets. The expected result is a model that opens only when its local conditions and a fresh approach are satisfied.
package nimby.mod
import nimby.*
import wiki.approach.approachSignal
fun createMod() = signalMod(modInfo) {
metadata(author = "Your name", description = "Approach-controlled signal.")
signal(approachSignal)
}observeApproach(blocks = 2) requests a train head directed towards the signal within the two upstream blocks. The range accepts 1 through 16 blocks. trainApproaching is true only for a usable, fresh approach; approachingTrain then supplies its identity. Missing evidence produces false/null, not proof that no train exists.
Traversal follows the observed direction and does not choose a branch arbitrarily. After the head passes the signal, that train is no longer approaching it. Detection proves neither reservation nor movement permission; combine it with block checks in your rule.