Manhunt Core Extension API v2
Core version audited: 1.2.0
Protocol version: 2
Bus ID: manhunt:bus
This document describes the extension protocol implemented by Manhunt Core v1.2.0. Extensions are separate Bedrock behavior packs and communicate with Core entirely through the Minecraft Bedrock Script API. No external process, host-side service, web server, or filesystem IPC is required.
1. Protocol overview
All Core/extension communication uses the script-event ID:
manhunt:bus
Every message is a JSON string with the same envelope:
{
"v": 2,
"source": "traitor",
"type": "register_begin",
"data": {}
}
Fields:
| Field | Meaning |
|---|---|
v |
Protocol version. Must be 2. |
source |
Stable sender ID. Core always uses "core"; extensions should use their extension ID. |
type |
Packet/event type. |
data |
Packet-specific object. |
Core ignores malformed JSON, packets with the wrong protocol version, and packets whose source is "core" when received from the bus.
Recommended listener
import { system } from "@minecraft/server";
const BUS = "manhunt:bus";
const PROTOCOL = 2;
const ID = "example_extension";
system.afterEvents.scriptEventReceive.subscribe((event) => {
if (event.id !== BUS) return;
let packet;
try { packet = JSON.parse(event.message); }
catch { return; }
if (packet?.v !== PROTOCOL) return;
if (packet?.source !== "core") return;
// Handle Core packet here.
}, { namespaces: ["manhunt"] });
Recommended sender
function send(type, data = {}) {
system.sendScriptEvent(BUS, JSON.stringify({
v: PROTOCOL,
source: ID,
type,
data
}));
}
2. Extension IDs and normalization
Core normalizes extension IDs, setting keys, action IDs, and hold IDs by:
- converting to lowercase,
- trimming whitespace,
- removing characters other than
a-z,0-9,_,., and-, - truncating to 64 characters.
Use a short, stable ID such as:
traitor
classes
advanced_config
purpleforge.contracts
Do not use core as an extension ID.
For clarity and predictable routing, an extension should use the same value for its packet source and registration data.id.
3. Discovery and registration
Core's extension registry is in-memory. Extension settings are persistent, but the extension's active schema, actions, name, description, and status must be registered again after a world/script reload.
Core admin command permission model
/manhunt:control is operator-only at the Core application layer.
For Realm compatibility, Core registers the custom command with CommandPermissionLevel.Any and cheatsRequired: false, then checks the invoking player's effective playerPermissionLevel before opening the control panel. Players below operator/GameDirector permission level are rejected.
Extensions that add their own admin-only custom commands should use the same pattern when Realm compatibility matters: register the command permissively enough to reach the callback, then perform the authoritative operator check before privileged behavior.
/manhunt:status remains intentionally available to any player.
Core broadcasts discover:
- shortly after Core starts,
- once every 60 seconds,
- whenever an admin presses Rediscover Extensions in
/manhunt:control.
Packet:
{
"v": 2,
"source": "core",
"type": "discover",
"data": {
"coreVersion": "1.2.0",
"protocolVersion": 2
}
}
Extensions should respond to every discover by registering again. Extensions should also register once on their own startup so they do not have to wait for the next discovery broadcast.
Why registration is chunked
Do not put a large UI schema into one script-event message. Extensions such as Advanced Configuration may eventually expose dozens or hundreds of settings.
The preferred registration flow is:
register_begin
register_setting (zero or more)
register_action (zero or more)
register_end
This keeps individual bus messages small and lets Core build large control surfaces incrementally.
4. Registration packets
register_begin
Begins/replaces an extension registration draft.
send("register_begin", {
id: ID,
name: "Manhunt: Traitor",
version: "1.0.0",
description: "Adds a hidden Traitor role among the Hunters.",
status: "Ready",
capabilities: {
preStartHold: true,
hiddenRole: true
}
});
Supported metadata:
| Field | Required | Core limit / behavior |
|---|---|---|
id |
Recommended | Normalized; max 64 chars. Falls back to packet source. |
name |
No | Max 80 chars. Defaults to ID. |
version |
No | Max 32 chars. Defaults to 0.0.0. |
description |
No | Max 400 chars. |
status |
No | Max 120 chars. Defaults to Ready. |
capabilities |
No | Arbitrary object copied into the registry. Core v1.2.0 does not automatically act on capability flags. |
Calling register_begin again for the same ID replaces the unfinished draft.
register_setting
Adds or replaces one setting in the current registration draft.
send("register_setting", {
id: ID,
setting: {
key: "supplyIntervalSeconds",
label: "Supply interval (seconds)",
description: "How often a covert supply attempt occurs.",
category: "Supplies",
type: "number",
default: 30,
min: 10,
max: 180,
step: 5
}
});
If a setting with the same normalized key is registered twice during one draft, the later definition replaces the earlier one.
register_action
Adds or replaces one admin action in the current registration draft.
send("register_action", {
id: ID,
action: {
id: "reissue_selector",
label: "Reissue Traitor Selector",
description: "Give the Runner another selector item.",
category: "Actions"
}
});
If an action with the same normalized id is registered twice during one draft, the later definition replaces the earlier one.
register_end
Commits the registration draft to Core's active extension registry.
send("register_end", { id: ID });
Core then:
- makes the extension visible in the Extensions UI,
- loads any previously persisted settings for that extension ID,
- inserts defaults only for registered keys that do not already exist,
- persists those newly inserted defaults,
- broadcasts
extension_registered.
extension_registered response
{
"v": 2,
"source": "core",
"type": "extension_registered",
"data": {
"id": "traitor",
"coreVersion": "1.2.0",
"settings": {
"enabled": true,
"supplyIntervalSeconds": 30
}
}
}
Extensions should treat this packet as the authoritative persisted settings snapshot immediately after registration.
Legacy single-packet registration
Core v1.2.0 still accepts a protocol-v2 register packet containing settings and actions arrays. This exists for compatibility with early extension prototypes.
New extensions should use chunked registration instead.
5. Setting schema
Core currently supports four setting types:
boolean
number
choice
text
All registered settings are rendered automatically inside /manhunt:control.
Common setting fields
| Field | Behavior |
|---|---|
key |
Required after normalization; max 64 chars. |
label |
Display label; max 80 chars. Defaults to key. |
description |
Tooltip/help text; max 220 chars. |
category |
UI grouping; max 60 chars. Defaults to General. |
type |
boolean, number, choice, or text. Unknown values become text. |
default |
Initial value used only if no persisted value exists for this key. |
Core displays up to 12 settings per category page and automatically paginates additional settings.
Boolean
{
"key": "enabled",
"label": "Enabled",
"category": "General",
"type": "boolean",
"default": true
}
Number
{
"key": "count",
"label": "Count",
"category": "Rules",
"type": "number",
"default": 1,
"min": 0,
"max": 100,
"step": 1
}
For number settings:
- missing/invalid
minbecomes0, - missing/invalid
maxbecomes100, - missing/non-positive
stepbecomes1.
Extensions should still register sensible min <= default <= max values; Core does not attempt to repair every malformed numeric schema.
Choice
{
"key": "mode",
"label": "Mode",
"category": "Rules",
"type": "choice",
"default": "classic",
"choices": ["classic", "chaos", "secret"]
}
Core accepts up to 100 choice strings, each truncated to 80 characters. If no choices are registered, Core renders a single Default option.
The persisted value is the selected choice string, not its numeric index.
Text
{
"key": "message",
"label": "Announcement",
"category": "Messages",
"type": "text",
"default": "A traitor walks among you."
}
Persistence rules
Extension settings are stored by extension ID in world dynamic properties.
Important behavior:
- re-registering does not overwrite an existing saved value with a new default,
- removing a setting from the current schema hides it from the Core UI but does not currently delete its old stored key,
- an extension should ignore unknown/stale keys in the settings object,
- changing a setting key creates a new setting from Core's perspective.
6. Settings updates from Core
When an admin saves an extension settings page, Core broadcasts:
{
"v": 2,
"source": "core",
"type": "extension_config_changed",
"data": {
"id": "traitor",
"settings": {
"enabled": true,
"supplyIntervalSeconds": 30
},
"actorId": "...",
"actorName": "..."
}
}
An extension should ignore this packet when data.id does not match its ID.
settings is the full persisted settings object Core has for that extension, not only the field changed on the current page.
7. Extension admin actions
Actions appear as buttons on the extension's page in Core's admin UI.
Action schema:
{
"id": "reroll",
"label": "Reroll Roles",
"description": "Choose new hidden roles.",
"category": "Actions"
}
Limits:
| Field | Limit / default |
|---|---|
id |
Normalized, max 64 chars. |
label |
Max 80 chars; defaults to action ID. |
description |
Max 220 chars. |
category |
Max 60 chars; defaults to Actions. Currently stored but Core v1.2.0 does not visually group action buttons by category. |
When an admin presses the action, Core sends:
{
"v": 2,
"source": "core",
"type": "extension_action",
"data": {
"extensionId": "traitor",
"actionId": "reissue_selector",
"actorId": "...",
"actorName": "..."
}
}
The extension owns the action's behavior. If it needs a specialized picker or workflow, it may locate the admin by actorId and open its own @minecraft/server-ui form.
8. Match lifecycle
Core v1.2.0 has four states:
idle
preparing
running
ended
Normal flow:
idle / ended
|
| admin starts match
v
preparing
|
| extensions acquire/release start holds
| all Runner/Hunter movement remains frozen
|
| all holds cleared + countdown finishes
v
running
|
| win condition or admin end
v
ended
Critical semantic rule
match_start means gameplay is live.
Extensions that need to do setup before movement begins must use match_prepare and pre-start holds. They should not perform blocking setup in match_start.
9. match_prepare
When an admin starts a valid match, Core:
- increments
matchId, - enters
preparing, - clears old holds,
- freezes all online players whose Core role is Runner or Hunter,
- broadcasts
match_prepare, - gives extensions a short settling window before evaluating holds.
Packet:
{
"v": 2,
"source": "core",
"type": "match_prepare",
"data": {
"matchId": 12,
"preparedAt": 1788800000000,
"resumed": false,
"actorId": "...",
"actorName": "AdminName",
"roles": [
{ "id": "...", "name": "RunnerName", "role": "runner" },
{ "id": "...", "name": "HunterName", "role": "hunter" }
]
}
}
Fields:
| Field | Meaning |
|---|---|
matchId |
Current match number. |
preparedAt |
Date.now() timestamp when this prepare event was emitted. |
resumed |
false for a newly started preparation; true when Core restores an interrupted preparing state after reload. |
actorId / actorName |
Admin who started preparation. May be absent on resume. |
roles |
Snapshot of online players and Core roles at emission time. |
Realm/world reload during preparation
If Core initializes and finds the saved state is still preparing, it:
- clears the in-memory hold table,
- freezes participants again,
- re-emits
match_preparewithresumed: true.
An extension that still needs setup must reacquire its hold when it receives the resumed event.
10. Pre-start holds
A hold prevents the preparing match from entering its countdown.
Acquire a hold
send("start_hold_acquire", {
holdId: "traitor_selection",
reason: "Waiting for Runner to choose a Traitor"
});
Core only accepts new holds while state is preparing.
A hold is uniquely identified by:
<extension source>:<holdId>
This means:
- two different extensions may use the same
holdIdwithout colliding, - one extension may hold startup for multiple independent reasons,
- acquiring the same hold again updates/replaces that hold's reason instead of creating a duplicate.
holdId is normalized like an extension ID and limited to 64 characters. reason is truncated to 160 characters.
Release a hold
send("start_hold_release", {
holdId: "traitor_selection"
});
An extension releases its own hold because Core keys the hold using the packet's source.
Hold-state broadcast
After every acquire or release request, Core broadcasts:
{
"v": 2,
"source": "core",
"type": "start_hold_state",
"data": {
"holds": [
{
"source": "traitor",
"holdId": "traitor_selection",
"reason": "Waiting for Runner to choose a Traitor"
}
]
}
}
Core begins the configured countdown only when holds is empty.
Freeze behavior
While Core is preparing:
- Runner and Hunter movement input is disabled,
- Spectators and players with role
noneare not frozen by Core, - newly spawned/rejoined Runner/Hunter players are frozen shortly after spawning,
- Core remembers each affected player's prior movement-permission state and restores it when preparation ends.
All holds are cleared when preparation is aborted, the match starts, the match ends, or Core is reset to the lobby.
11. match_start
After all holds are gone and the configured countdown finishes, Core:
- changes state to
running, - records the actual start timestamp,
- clears any remaining holds,
- restores participant movement,
- broadcasts
match_start.
Packet:
{
"v": 2,
"source": "core",
"type": "match_start",
"data": {
"matchId": 12,
"startedAt": 1788800010000,
"roles": [
{ "id": "...", "name": "RunnerName", "role": "runner" },
{ "id": "...", "name": "HunterName", "role": "hunter" }
]
}
}
Use this event to start gameplay timers, periodic rewards, scoring, objectives, and other mechanics that should only run once players are actually released.
12. Preparation cancellation
If an admin cancels while Core is preparing, Core returns to idle and broadcasts:
{
"v": 2,
"source": "core",
"type": "match_prepare_abort",
"data": {
"matchId": 12,
"reason": "Canceled by admin",
"actorId": "...",
"actorName": "AdminName"
}
}
Extensions should cancel pending pre-match UI/work, remove temporary setup items if appropriate, and clear match-specific preparation state.
13. Match end and reset
match_end
Emitted when a running match ends:
{
"v": 2,
"source": "core",
"type": "match_end",
"data": {
"matchId": 12,
"winner": "runner",
"reason": "The Ender Dragon was defeated.",
"actorId": "...",
"actorName": "..."
}
}
actorId and actorName may be absent for automatic win conditions.
Core's built-in winner strings are currently:
runner
hunters
manual
manual is used by the standard admin End Match action. Extensions should not assume those are the only strings that will ever exist in future Core versions.
match_reset
Emitted when Core is reset to lobby:
{
"v": 2,
"source": "core",
"type": "match_reset",
"data": {
"matchId": 12,
"actorId": "...",
"actorName": "AdminName"
}
}
Extensions should clear transient match state as appropriate.
14. Core role model
Core owns only these primary roles:
runner
hunter
spectator
none
They correspond to the public Bedrock tags:
runner
hunter
spectator
none means none of those tags are present.
Core permits only one online Runner through its role-assignment UI/API logic. Assigning a new Runner removes the previous Runner's Core role.
Special-role rule
Extension roles should normally be overlays, not replacements for Core roles.
Example:
Core role: Hunter
Traitor extension overlay: Traitor
The extension can persist the overlay with its own tag, world/player dynamic property, scoreboard, or other state. Core still sees the player as a Hunter for universal Manhunt behavior.
15. Core-to-extension event reference
role_changed
{
"playerId": "...",
"playerName": "Player",
"oldRole": "hunter",
"newRole": "runner",
"actorId": "...",
"actorName": "AdminName"
}
actorId / actorName may be absent for programmatic changes without an actor.
player_spawn
{
"playerId": "...",
"playerName": "Player",
"role": "hunter",
"initialSpawn": false
}
initialSpawn is copied from Bedrock's player-spawn event.
entity_death
{
"entityId": "...",
"typeId": "minecraft:player",
"playerName": "Player",
"role": "hunter",
"damagingEntityId": "...",
"damagingEntityType": "minecraft:player",
"cause": "entityAttack"
}
Optional fields may be absent when Bedrock does not provide the corresponding entity/cause information. playerName and role are only meaningful for dead players.
tracker_given
{
"playerId": "...",
"playerName": "Hunter"
}
core_config_changed
{
"config": {
"announceEvents": true,
"runnerDeathEndsMatch": true,
"dragonDeathEndsMatch": true,
"requireRunnerToStart": true,
"requireHunterToStart": true,
"startCountdownSeconds": 5
},
"actorId": "...",
"actorName": "AdminName"
}
tracker_config_changed
{
"config": {
"enabled": true,
"activeOnlyDuringMatch": true,
"autoGive": true,
"replaceLost": true,
"updateTicks": 5,
"showDistance": true,
"distanceRounding": 1,
"trackingDelaySeconds": 0,
"exactTrackerOnly": true,
"crossDimensionMode": "dimension",
"trackerName": "§r§6Runner Tracker"
},
"actorId": "...",
"actorName": "AdminName"
}
extension_config_changed
Documented in Settings updates from Core.
extension_action
Documented in Extension admin actions.
extension_registered
Documented in Registration packets.
discover
Documented in Discovery and registration.
state_snapshot
Documented below.
Lifecycle events
match_preparestart_hold_statematch_startmatch_prepare_abortmatch_endmatch_reset
See the lifecycle sections above for exact semantics and payloads.
16. Packets an extension may send to Core
Core v1.2.0 handles these packet types from extensions:
| Packet | Purpose |
|---|---|
register_begin |
Begin chunked registration. |
register_setting |
Add/replace one setting in current draft. |
register_action |
Add/replace one action in current draft. |
register_end |
Commit registration. |
register |
Legacy protocol-v2 single-packet registration. |
status |
Update UI status text for a registered extension. |
request_state |
Ask Core to broadcast its current state snapshot. |
start_hold_acquire |
Block a preparing match from beginning. |
start_hold_release |
Release one of this extension's holds. |
runner_win_claim_acquire |
Claim ownership of a custom Runner win condition for the active match. |
runner_win_claim_release |
Release one of this extension's Runner win-condition claims. |
request_match_end |
Ask Core to authoritatively end the running match with a validated winner/reason. |
notify |
Send a short extension message to online admins. |
status
send("status", {
id: ID,
status: "Traitor selected"
});
Status text is truncated to 120 characters. Updating status only works after the extension is currently registered.
request_state
send("request_state");
Core responds by broadcasting state_snapshot to the bus.
state_snapshot
{
"v": 2,
"source": "core",
"type": "state_snapshot",
"data": {
"coreVersion": "1.2.0",
"protocolVersion": 2,
"state": "preparing",
"matchId": 12,
"startHolds": [
{
"source": "traitor",
"holdId": "traitor_selection",
"reason": "Waiting for Runner to choose a Traitor"
}
],
"runnerWinClaims": [
{
"source": "win_conditions",
"claimId": "runner_objective",
"matchId": 12,
"label": "Obtain 1 × Diamond"
}
]
}
}
state_snapshot is broadcast, not directly addressed to the requesting extension. Every extension may receive it. runnerWinClaims contains the active claims for the current matchId and is empty when Core is using its normal Runner victory behavior.
notify
send("notify", {
message: "Traitor setup needs admin attention."
});
Core truncates the message to 500 characters and sends it to online players whose permission level is at least operator/admin level.
Use this for short operational notices, not continuous logging.
17. Recommended extension startup pattern
A robust extension should:
- subscribe to the bus,
- register itself shortly after startup,
- request a state snapshot,
- re-register whenever
discoverarrives, - load persisted settings from
extension_registered, - update its local settings when
extension_config_changedarrives, - reconcile persistent match state after
state_snapshot, - reacquire required holds on resumed
match_prepare.
Example skeleton:
import { system } from "@minecraft/server";
const BUS = "manhunt:bus";
const PROTOCOL = 2;
const ID = "example";
let coreState = "idle";
let coreMatchId = 0;
let settings = { enabled: true };
function send(type, data = {}) {
try {
system.sendScriptEvent(BUS, JSON.stringify({
v: PROTOCOL,
source: ID,
type,
data
}));
} catch {}
}
function register() {
send("register_begin", {
id: ID,
name: "Manhunt: Example",
version: "1.0.0",
description: "Example Core extension.",
status: "Ready"
});
send("register_setting", {
id: ID,
setting: {
key: "enabled",
label: "Enabled",
category: "General",
type: "boolean",
default: true
}
});
send("register_end", { id: ID });
}
function handleCore(packet) {
if (packet?.v !== PROTOCOL || packet?.source !== "core") return;
switch (packet.type) {
case "discover":
register();
break;
case "extension_registered":
if (packet.data?.id === ID) {
settings = { ...settings, ...packet.data.settings };
}
break;
case "extension_config_changed":
if (packet.data?.id === ID) {
settings = { ...settings, ...packet.data.settings };
}
break;
case "state_snapshot":
coreState = String(packet.data?.state ?? coreState);
coreMatchId = Number(packet.data?.matchId ?? coreMatchId);
break;
case "match_prepare":
coreState = "preparing";
coreMatchId = Number(packet.data?.matchId ?? coreMatchId);
// Acquire a hold here if this extension needs pre-match setup.
break;
case "match_start":
coreState = "running";
coreMatchId = Number(packet.data?.matchId ?? coreMatchId);
break;
case "match_prepare_abort":
case "match_reset":
coreState = "idle";
break;
case "match_end":
coreState = "ended";
break;
}
}
system.afterEvents.scriptEventReceive.subscribe((event) => {
if (event.id !== BUS) return;
let packet;
try { packet = JSON.parse(event.message); }
catch { return; }
handleCore(packet);
}, { namespaces: ["manhunt"] });
system.runTimeout(() => {
register();
send("request_state");
}, 20);
18. Match IDs and persistent extension state
Use matchId to prevent state from one Manhunt leaking into another.
Recommended pattern for any persistent per-match extension state:
storedAssignedMatch == currentCoreMatchId
If they differ, treat the stored assignment as belonging to an old match.
For player identity that must survive a disconnect, avoid relying only on an in-memory Player object. Extensions may persist both the player's runtime ID and name, then reconcile on reconnect. Manhunt: Traitor v1.0.0 uses this approach.
19. Win-condition extensions
Core v1.2.0 exposes a formal Runner win-condition ownership API. This allows an extension to replace the normal Ender Dragon objective instead of merely adding another way for the Runner to win.
Core still has two built-in automatic outcomes:
- Runner death ->
winner: "hunters" - Ender Dragon death ->
winner: "runner"
The Runner-death rule continues to follow the Core Settings toggle. The Ender Dragon rule is automatically suppressed while at least one active Runner win-condition claim exists for the current match.
Acquire a Runner win-condition claim
An extension may acquire a claim while Core is preparing or running:
send("runner_win_claim_acquire", {
claimId: "runner_objective",
matchId: coreMatchId,
label: "Kill the Warden"
});
A claim is keyed by:
<extension source>:<claimId>
Rules:
claimIdis normalized like an extension ID and defaults todefault.labelis informational and truncated to 160 characters.matchIddefaults to the current match, but if provided it must equal Core's currentmatchId.- Acquiring the same claim again replaces its label rather than creating a duplicate.
- Claims are stored in a world dynamic property and therefore survive a Core/script reload for the same match.
- Claims from old match IDs are ignored.
Core broadcasts the current claim list whenever it changes:
{
"v": 2,
"source": "core",
"type": "runner_win_claim_state",
"data": {
"matchId": 12,
"claims": [
{
"source": "win_conditions",
"claimId": "runner_objective",
"matchId": 12,
"label": "Kill the Warden"
}
]
}
}
While claims is non-empty, Core's built-in Ender Dragon death handler does not award the Runner victory. Other Core outcomes, including Runner death -> Hunters, are unchanged unless separately disabled in Core Settings.
Release a claim
send("runner_win_claim_release", {
claimId: "runner_objective",
matchId: coreMatchId
});
The packet source determines which extension owns the released claim. An extension cannot release another extension's claim.
Core clears all Runner win claims when:
- a new match preparation begins,
- preparation is canceled,
- a running match ends, or
- Core resets to lobby.
Request an authoritative match end
A currently registered extension can ask Core to end a running match:
send("request_match_end", {
matchId: coreMatchId,
winner: "runner",
reason: "The Runner killed the Warden."
});
Core validates all of the following before honoring the request:
- the packet source is currently registered as an extension,
- Core state is
running, matchIdequals the active Core match ID,winneris currently one ofrunner,hunters, ormanual.
reason is truncated to 240 characters. If accepted, Core uses its normal endMatch path, persists the result, announces it according to Core settings, clears claims/holds, and emits the standard match_end event.
Core also broadcasts a result packet for the request:
{
"v": 2,
"source": "core",
"type": "match_end_request_result",
"data": {
"extensionId": "win_conditions",
"matchId": 12,
"accepted": true,
"winner": "runner",
"reason": "The Runner killed the Warden."
}
}
Rejected requests use accepted: false and place the rejection explanation in reason. This packet is broadcast on the bus, so extensions should check extensionId and matchId before acting on it.
Recommended custom-objective pattern
A replacement Runner objective should normally:
- acquire its claim during
match_prepare, - use a start hold if its objective configuration is incomplete,
- release the hold once configuration is valid,
- begin gameplay detection on
match_start, - call
request_match_endwithwinner: "runner"when complete, - reconcile/reacquire its claim after
state_snapshotif Core reloads during an active match.
Manhunt: Win Conditions v1.0.0 is the reference implementation for this pattern.
20. Compatibility and forward-compatibility rules
Extensions should follow these rules:
- Ignore packets with an unsupported protocol version.
- Ignore unknown packet types.
- Ignore unknown fields inside known packets.
- Do not assume optional
actorId,actorName, damage-source fields, or player-specific fields are always present. - Treat
winneras an extensible string. - Treat settings objects as extensible and ignore unknown keys.
- Re-register on every
discover. - Use
match_prepare+ holds for blocking pre-match work. - Use
match_startonly for gameplay that begins after players are released. - Use stable extension/setting/action IDs; changing IDs breaks continuity with persisted configuration.
- Keep bus packets reasonably small even though registration is chunked.
21. Current protocol-v2 event index
Core -> extensions
discover
extension_registered
state_snapshot
match_prepare
start_hold_state
runner_win_claim_state
match_start
match_prepare_abort
match_end
match_reset
role_changed
player_spawn
entity_death
tracker_given
core_config_changed
tracker_config_changed
extension_config_changed
extension_action
match_end_request_result
Extensions -> Core
register_begin
register_setting
register_action
register_end
register (legacy v2 compatibility)
status
request_state
start_hold_acquire
start_hold_release
runner_win_claim_acquire
runner_win_claim_release
request_match_end
notify
22. Reference implementations
Manhunt: Traitor v1.0.0 demonstrates the intended protocol-v2 architecture:
- registers through chunked schema messages,
- receives persisted configuration through
extension_registered, - acquires a pre-start hold during
match_prepare, - releases the hold only after the Runner chooses a Traitor,
- starts its 30-second supply timer on
match_start, - restores its hidden-role overlay after reconnects,
- cleans up on
match_prepare_abort,match_reset, andmatch_end, - exposes
reissue_selectoras an extension admin action, - requests a state snapshot on startup for reload recovery.
Manhunt: Win Conditions v1.0.0 demonstrates Runner objective ownership:
- acquires a Runner win-condition claim during
match_prepare, - uses a pre-start hold for invalid configuration,
- suppresses Core's vanilla Dragon victory through the claim rather than changing Core settings,
- watches Core events / player state for custom objective completion,
- ends the match through
request_match_end, - validates the
match_end_request_result, - restores ownership after state reconciliation.
Use Traitor as the reference for pre-match secret setup and overlay roles. Use Win Conditions as the reference for extensions that replace authoritative match outcomes.