This guide describes the current architecture and development workflow of Scripted Events Reloaded (SER). It is intended for contributors and release maintainers. The implementation remains the source of truth; user-facing script syntax is documented separately in language_specification.md and through the in-game serhelp command.
Last verified against the repository: 2026-07-13.
SER is a .NET Framework 4.8 plugin that compiles and executes .ser/.txt scripts inside an SCP:SL server. The same project produces two distributions:
| Configuration | Plugin host | Assembly | Output directory |
|---|---|---|---|
Release |
LabAPI | SER.dll |
bin/LABAPI/net48/ |
EXILED |
EXILED | SER-Exiled.dll |
bin/EXILED/net48/ |
Full Debug |
LabAPI, DEBUG and SIGNAL enabled |
SER.dll |
bin/LABAPI/net48/ |
Partial Debug |
LabAPI, SIGNAL enabled |
SER.dll |
bin/LABAPI/net48/ |
The repository currently has no standalone unit-test project. The build does contain an important integration check: after compiling the DLL, MSBuild loads it, indexes its methods, compiles every embedded example script, and initializes several registries. This catches many parser and registration regressions, but it does not simulate a running game server.
| Path | Responsibility |
|---|---|
Code/Plugin |
Plugin entry point, configuration, commands, framework lifecycle |
Code/FileSystem |
Script discovery, example generation, databases |
Code/ScriptSystem |
Script construction, compilation, execution, executors |
Code/TokenSystem |
Line splitting, slices, tokens, lexical precedence |
Code/ContextSystem |
Context tree construction and executable statements |
Code/ArgumentSystem |
Method argument definitions, conversion, typed access |
Code/MethodSystem |
Method registry, base classes, built-in script methods |
Code/FlagSystem |
Script metadata flags and their bindings |
Code/EventSystem |
Dynamic LabAPI event discovery and script event handlers |
Code/VariableSystem |
Local/global variables and predefined player variables |
Code/ValueSystem |
Literal, player, collection and reference values; properties |
Code/Builders |
Generated help metadata and the Blockly visual editor |
Example Scripts |
Scripts embedded into the plugin and compiled during every build |
language_specification.md |
LLM-oriented/user-facing language reference |
Important root files:
SER.csprojdefines all configurations, dependencies, embedded resources, post-build copying and build-time validation.App.configcontains .NET Framework binding redirects.global.jsonselects the .NET SDK used by command-line builds.SER Visual Editor.htmlandser_method_info.jsare generated and intentionally ignored by Git.packages.configis legacy metadata;PackageReferenceentries inSER.csprojcontrol current NuGet restore.
- A Windows development environment with an SDK capable of building
net48and the .NET Framework 4.8 reference assemblies. SL_DEV_REFERENCESset to a directory containing the SCP:SL/LabAPI/Unity DLLs referenced with explicitHintPathentries inSER.csproj.- Optionally,
LABAPI_PLUGINSset to a LabAPI plugin directory for automatic local deployment.
Check the environment before building:
dotnet --info
$env:SL_DEV_REFERENCES
$env:LABAPI_PLUGINSdotnet --info must report global.json as valid. Use an SDK feature-band version such as 10.0.100, not a runtime-style version such as 10.0.0.
dotnet restore SER.sln
dotnet build SER.sln -c Release --no-restore
dotnet build SER.sln -c EXILED --no-restoreFor a complete pre-release check, also compile Full Debug and Partial Debug.
Build side effects matter:
- A non-EXILED build copies the resulting assembly to
LABAPI_PLUGINSwhen that variable is set. - Every build copies the assembly to
SL_DEV_REFERENCESwhen that variable is set. - The
RunValidationtarget loads the newly built DLL and compiles all embedded example scripts. - Validation calls
Builder.CreateFiles(), which regeneratesSER Visual Editor.htmlandser_method_info.jsin the working directory.
Do not interpret “build succeeded” as full server compatibility. Always smoke-test both release assemblies against the exact SCP:SL, LabAPI and EXILED versions intended for release.
Code/Plugin/MainPlugin.cs is the entry point for both hosts. Conditional compilation changes only the plugin base class and lifecycle method names.
On plugin enable, SER:
- Stores
MainPlugin.Instance. - Starts
FrameworkBridgediscovery for optional integrations. - Subscribes to map, server and player events.
- Registers custom Tesla and damage event handlers.
On MapGenerating, SER resets round-scoped state, then initializes systems in this order:
- Stop running scripts and clear registered script flags.
- Clear player data, Tesla rules, damage rules and custom roles.
- Initialize the reference-property registry.
- Register flags.
- Discover LabAPI events.
- Index built-in methods.
- Create predefined global variables.
- Initialize command-context capture.
- Discover scripts and bind their flags.
This order is significant. Script flag parsing requires methods, variables and events to be available. Any new registry that scripts depend on should be initialized before FileSystem.Initialize().
Lifecycle rule: every event subscription, command registration, custom handler and coroutine created during enable/initialization must have a matching cleanup path. Test repeated map generation as well as plugin disable/re-enable; a clean first boot alone does not expose duplicate subscriptions.
FileSystem.UpdateScriptPathCollection() recursively scans the SER config directory for .ser and .txt files.
- A file whose base filename starts with
#is ignored. - Physical-file identity is the filename without its extension, not its relative path. Runtime sections add a numbered selector when needed.
- If two files anywhere in the tree share the same base filename, all scripts with that duplicate name are excluded and an error is logged.
- Lookups refresh the path collection, so deleted or renamed scripts are detected without retaining a stale path.
During initialization, SER splits a physical file at every !-- declaration. Each declaration is inclusive in its section, and the section ends immediately before the next declaration. Multi-section files receive selectors such as file:1 and file:2; flagless and single-section files retain their bare filename. Only blank lines and comments may precede the first declaration in a flagged file. SER tokenizes the flag lines of each section and passes them to ScriptFlagHandler. Flags can then bind events, custom commands, custom roles or other major behavior. ScriptFlagHandler.Clear() calls Unbind() on every registered flag before clearing the registry.
Bindings store the section selector rather than only the physical filename, so callbacks reload and execute the correct slice. Section compilation retains original source-file line numbers. A bare multi-section filename is deliberately ambiguous for manual execution; use its numbered selector. File-level stop and running checks match all of that file's sections.
Script.Compile() runs three stages after defining numbered lines:
raw content
-> Line[]
-> Slice[] per line
-> BaseToken[] per line
-> RunnableContext[] tree
Tokenizer.SliceLine groups raw characters into:
SingleSlicefor ordinary ungrouped text;CollectionSlicefor quoted text and grouped expressions such as(...)and{...}.
The slice validates its own closing delimiter before tokenization.
Tokenizer tries token classes in explicit precedence order. There are separate lists for single and collection slices:
OrderedImportanceTokensFromSingleSlicesOrderedImportanceTokensFromCollectionSlices
The first token whose TryInit succeeds wins. Unknown input falls back to a plain BaseToken. Precedence is therefore part of the language grammar; adding or moving a token can change how existing scripts parse.
Contexter turns tokenized lines into executable contexts. A stack tracks nested StatementContext instances. Block starters are pushed, children are attached to the current statement, and end pops the current block. Extenders such as else, elif and onerror replace/extend the active statement according to their interfaces and signals.
Compilation returns Result/TryGet<T> errors with script line context. It should not depend on a live player or round unless a specific token/argument is explicitly dynamic.
Script.RunForEvent checks flag approval, injects any pre-execution state, adds the script to the running set and executes its root contexts through MEC coroutines.
Contexts fall into two main runtime categories:
StandardContextexecutes synchronously.YieldingContextreturnsIEnumerator<float>and may pause execution.
Methods follow the same split through SynchronousMethod and YieldingMethod, with returning variants for each. BetterCoros owns common coroutine error handling and observes a script's killed state. SafeScripts introduces frame yields around method execution, but it is not a substitute for explicit waits in intentionally long or infinite script loops.
Script.MarkAsStopped() removes a script from the running registry and sets Killed. New runtime work must remain attached to the script-aware coroutine path when cancellation and script-level error reporting are expected; detached coroutines can outlive their originating script.
The value hierarchy bridges script syntax with CLR and game objects:
| Prefix | Variable/value family | Examples |
|---|---|---|
$ |
literal | text, number, boolean, duration-like data |
@ |
player | one or more LabAPI players |
& |
collection | heterogeneous SER values |
* |
reference | wrapped game/API objects |
VariableIndex stores global variables and recreates the built-in player collections on every map. Each Script stores local variables keyed by (prefix, name). Variables with the same name but different prefixes are distinct.
Properties are accessed with ->. Property metadata is represented by PropInfo; built-in value properties live with value types, while external/game object properties are registered by ReferencePropertyRegistry. ValueExpressionContext resolves chained property access and returning expressions.
When exposing a new CLR object to scripts, decide whether it should become a literal, player, collection or reference value. Do not serialize live game wrappers directly into databases.
MethodIndex reflects over non-abstract Method subclasses. A class name must end in Method; underscores become dots in the script name. For example, HTTP_GetMethod is exposed as HTTP.Get. The namespace segment containing the class supplies its help/editor subgroup.
Framework-dependent methods implement IDependOnFramework. They are held separately until FrameworkBridge detects the required integration.
- Put the class in the appropriate
Code/MethodSystem/Methods/<Group>Methodsnamespace. - Derive from the correct synchronous/yielding and returning/non-returning base.
- Name the class with the required
Methodsuffix. - Provide
DescriptionandExpectedArguments. - Implement
Execute(). - Add
IAdditionalDescription,ICanError,IHasAliasesorIDependOnFrameworkwhen applicable. - Add or update an example script that exercises the new behavior.
- Build both release configurations and inspect generated help/editor metadata.
[UsedImplicitly] is recommended on reflection-created classes to keep IDE/static analysis accurate. It is not what performs runtime registration; inheritance and MethodIndex reflection do that.
- Derive from
Argument(or the closest specialized base). - Implement a public instance
GetConvertSolution(BaseToken)method returningDynamicTryGet<T>. - Set
MustBeProvided,DefaultValue,ConsumesRemainingValuesand descriptions deliberately. - Add a typed accessor in
ProvidedArgumentsif method implementations need a new convenience getter. - Test static values, dynamic values,
_defaults, missing arguments, extra arguments and consuming-the-rest behavior.
MethodArgumentDispatcher discovers and compiles the converter delegate by reflection, then caches it by argument type.
- Implement the correct token interface/base.
- Add ordinary token types to the appropriate ordered list in
Tokenizer. - For keywords, ensure
ContextableKeywordTokencan discover the keyword context. - Test precedence against booleans, methods, numbers, colors, variables, durations and wildcard tokens.
- Test invalid delimiters and an empty/whitespace-only line.
- Choose
StandardContext,YieldingContextorStatementContextbased on runtime behavior. - Implement
TryAddTokenandVerifyCurrentStateso incomplete syntax fails during compilation. - Use statement interfaces/signals for block extensions and parent-control messages for
break,continue,returnandstopsemantics. - Test nesting, a missing
end, an extraend, and every supported extender.
- Implement parsing/string representation without assuming a live game object for static compilation.
- Register reference properties in
ReferencePropertyRegistry. - Declare accurate
TypeOfValueinformation so argument and return-type validation works. - Ensure null/invalid references produce a script error rather than a raw CLR exception.
- Derive from
Flag; implement inline/secondary arguments,OnParsingComplete()andUnbind(). - Implement
IMajorBehaviorFlagwhen the flag owns primary script execution behavior. - Make binding and unbinding symmetric and safe when parsing fails halfway through.
- External plugins can use
Flag.RegisterFlagsAsExternalPlugin(); external methods can useMethodIndex.AddAllDefinedMethodsInAssembly().
Expected validation failures use Result, TryGet<T> or DynamicTryGet, not ordinary exceptions.
trueconverts to a successfulResult.- A non-empty string converts to an error
Result. Result + Resultadds context to an existing error.Result.Mergecombines independent compile errors.
Script runtime failures use ScriptRuntimeError/CustomScriptRuntimeError and should run through the script-aware coroutine wrapper so they can be logged against the script and line. Exceptions whose names indicate a developer error represent violated engine invariants; they are not appropriate for invalid user input.
Avoid broad catch blocks that discard the original exception. If recovery is possible, preserve enough context to identify the script, method, line and external operation.
EventHandler.Initialize() discovers static LabAPI handler events by reflection. A script using !-- OnEvent EventName causes SER to bind that event lazily. Event properties are converted into local variables named ev<PropertyName> with a prefix inferred from the value type. Cancellable events can propagate a script's allow/deny result.
DisableEvent and EnableEvent only accept events whose argument type implements LabAPI's ICancellableEvent. Both are returning methods: they return true when the disabled state changed and false when it was already in the requested state. A missing or non-cancellable event is a script runtime error.
sermethod detects a single synchronous ReturningMethod and includes its formatted value in the command response. Ordinary and yielding methods keep the normal coroutine execution path so a command cannot block the server thread.
EventHandler.Clear() executes stored unsubscribe actions and clears handler state. Any additional event binding mechanism should follow this pattern.
FrameworkBridge probes for EXILED, Callvote and UncomplicatedCustomRoles. Methods implementing IDependOnFramework are unavailable until their framework is detected and loaded. Release testing should cover both absence and presence of every optional framework represented in the shipped method set.
Scripts can reach databases, custom YAML configs, HTTP endpoints, Discord webhooks and audio/archive code. Treat all script-provided names, paths, URLs and serialized content as untrusted input even when scripts are normally written by server administrators.
- Resolve file paths and verify that the final full path remains inside the intended SER subdirectory.
- Reject rooted paths and
..traversal. - Database JSON uses a restricted serialization binder; keep its explicit type allowlist narrow when adding new persisted values.
- Persist game structs through stable SER representations rather than serializing wrapper objects directly. Colors are stored as canonical hex strings and parsed back into
ColorValueinstances. - Put timeouts and response-size limits on network operations.
- Keep asynchronous operations attached to script cancellation/error handling.
- Audit embedded/transitive dependencies, because selected DLLs are packed into the plugin assembly.
Builder.CreateFiles() produces:
ser_method_info.js, a JSON-like truth table of methods and arguments;SER Visual Editor.html, a Blockly-based visual script editor.
The build embeds example scripts and selected dependencies (NCalc, Newtonsoft.Json, AudioPlayerApi, NVorbis, SharpCompress) into the plugin assembly. It also attempts to embed SER and API XML documentation used by help generation.
Before publishing, inspect the final DLL's manifest resources and confirm that required examples, dependency DLLs and XML files are present. Test from a clean checkout/build directory so stale XML or generated output cannot mask packaging problems.
-
git status --shortcontains only intended source/documentation changes. -
dotnet --infoacceptsglobal.jsonand selects the intended SDK. - Restore succeeds from a clean NuGet cache or CI agent.
-
Release,EXILED,Full DebugandPartial Debugall build with zero warnings. - Build-time validation compiles every embedded example.
-
dotnet list SER.csproj package --vulnerable --include-transitivereports no accepted-unreviewed vulnerability. - Final LabAPI and EXILED assemblies contain the intended embedded resources.
- Version, changelog/release notes, README, language specification, examples and in-game help agree.
- Enable and disable the plugin without retained event handlers or coroutines.
- Generate at least two maps and verify callbacks/commands execute exactly once.
- Load valid scripts, reject malformed scripts and reject duplicate filenames cleanly.
- Run event, command, function, loop, callback and returning-method examples.
- Stop/reload scripts while they are waiting or performing network work.
- Exercise cancellable event disable/enable behavior and verify returned values.
- Create/read/update databases and configs, including malicious path inputs.
- Test HTTP/Discord failures, timeouts and invalid JSON.
- Test with no optional frameworks, then with each supported framework combination.
- Repeat the matrix for both LabAPI and EXILED artifacts.
The build-time example validator proves that the assembly can be loaded, core method registration succeeds, and shipped examples compile. It does not currently prove:
- plugin enable/disable symmetry;
- behavior across repeated maps or hot reloads;
- real LabAPI/EXILED event compatibility;
- correctness of return values and game mutations;
- coroutine cancellation and asynchronous exception handling;
- filesystem containment, network limits or serialization safety;
- generated editor behavior in a browser.
Changes in these areas require targeted tests or a clean-server smoke test even when every build is green.