Skip to content

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.

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"] });
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:

  1. converting to lowercase,
  2. trimming whitespace,
  3. removing characters other than a-z, 0-9, _, ., and -,
  4. 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:

  1. makes the extension visible in the Extensions UI,
  2. loads any previously persisted settings for that extension ID,
  3. inserts defaults only for registered keys that do not already exist,
  4. persists those newly inserted defaults,
  5. 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 min becomes 0,
  • missing/invalid max becomes 100,
  • missing/non-positive step becomes 1.

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:

  1. increments matchId,
  2. enters preparing,
  3. clears old holds,
  4. freezes all online players whose Core role is Runner or Hunter,
  5. broadcasts match_prepare,
  6. 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:

  1. clears the in-memory hold table,
  2. freezes participants again,
  3. re-emits match_prepare with resumed: 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 holdId without 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 none are 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:

  1. changes state to running,
  2. records the actual start timestamp,
  3. clears any remaining holds,
  4. restores participant movement,
  5. 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_prepare
  • start_hold_state
  • match_start
  • match_prepare_abort
  • match_end
  • match_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.


A robust extension should:

  1. subscribe to the bus,
  2. register itself shortly after startup,
  3. request a state snapshot,
  4. re-register whenever discover arrives,
  5. load persisted settings from extension_registered,
  6. update its local settings when extension_config_changed arrives,
  7. reconcile persistent match state after state_snapshot,
  8. 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:

  • claimId is normalized like an extension ID and defaults to default.
  • label is informational and truncated to 160 characters.
  • matchId defaults to the current match, but if provided it must equal Core's current matchId.
  • 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:

  1. the packet source is currently registered as an extension,
  2. Core state is running,
  3. matchId equals the active Core match ID,
  4. winner is currently one of runner, hunters, or manual.

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.

A replacement Runner objective should normally:

  1. acquire its claim during match_prepare,
  2. use a start hold if its objective configuration is incomplete,
  3. release the hold once configuration is valid,
  4. begin gameplay detection on match_start,
  5. call request_match_end with winner: "runner" when complete,
  6. reconcile/reacquire its claim after state_snapshot if 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 winner as 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_start only 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, and match_end,
  • exposes reissue_selector as 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.