Skip to content

Mod Integration Examples

artriy edited this page Jul 17, 2026 · 5 revisions

Examples

These examples show API 1.1 behavior that works today. Role and modifier checks such as GetRole<T>, GetModifier<T>, and MyRoles.* are placeholders for your own mod's compile-time API.

All examples share one registration class. Call Register() only after the literal plugin-id presence check shown in Mod Integration. Keep only the recipe methods you need:

using PerfectComms.Api;
using UnityEngine;

internal static class PerfectCommsVoiceIntegration
{
    private const string Mod = "com.me.mymod";

    internal static void Register()
    {
        PerfectCommsApi.RegisterModTab(Mod, "My Mod");

        RegisterBlackmailer();
        RegisterJailor();
        RegisterPuppeteer();
        RegisterMedium();
        RegisterHiddenVoice();
        RegisterHackerJam();
        RegisterOverlayPrivacy();
    }

    internal static void Unregister()
        => PerfectCommsApi.Unregister(Mod);

    // Paste the selected recipe methods below into this class.
}

Call Unregister() before a dynamic unload or reload. Normal process exit needs no special cleanup.

Back to Mod Integration


Blackmailer

Behavior: a currently blackmailed living player cannot talk during a meeting. A separate host option can keep the player muted during the following task round.

Primitive: Gate.

private static void RegisterBlackmailer()
{
    PerfectCommsApi.RegisterHostOption(Mod,
        new VoiceHostOption(
            "MuteBlackmailed",
            "Blackmailer: Mute in Meetings",
            true));
    PerfectCommsApi.RegisterHostOption(Mod,
        new VoiceHostOption(
            "MuteBlackmailedNextRound",
            "Blackmailer: Mute Next Round",
            false));

    PerfectCommsApi.RegisterVoiceRule(Mod, ctx =>
    {
        if (ctx.IsDead)
            return VoiceRuleResult.Pass;

        // Current meeting state and persisted next-round state are separate.
        // Do not put a current-modifier guard in front of both branches.
        if (ctx.Phase == VoicePhaseKind.Meeting &&
            ctx.GetOption("MuteBlackmailed") &&
            ctx.Player.GetModifier<BlackmailedModifier>() != null)
        {
            return VoiceRuleResult.Mute("Blackmailed");
        }

        if (ctx.Phase == VoicePhaseKind.Tasks &&
            ctx.GetOption("MuteBlackmailedNextRound") &&
            MyRoles.WasBlackmailedLastMeeting(ctx.Player))
        {
            return VoiceRuleResult.Mute("Blackmailed");
        }

        return VoiceRuleResult.Pass;
    });
}

WasBlackmailedLastMeeting is your bookkeeping. Record the affected player when the meeting ends, then clear the state at the boundary your design requires.


Jailor

Behavior: a jailed living player is muted in meetings until the Jailor's already-synchronized state allows them to speak.

Primitive: Gate.

private static void RegisterJailor()
{
    PerfectCommsApi.RegisterHostOption(Mod,
        new VoiceHostOption(
            "MuteJailed",
            "Jailor: Mute Jailee in Meetings",
            true));

    PerfectCommsApi.RegisterVoiceRule(Mod, ctx =>
    {
        if (ctx.Phase != VoicePhaseKind.Meeting ||
            ctx.IsDead ||
            !ctx.GetOption("MuteJailed"))
        {
            return VoiceRuleResult.Pass;
        }

        if (ctx.Player.GetModifier<JailedModifier>() == null)
            return VoiceRuleResult.Pass;

        return MyRoles.JailorAllowedVoice(ctx.Player)
            ? VoiceRuleResult.Pass
            : VoiceRuleResult.Mute("Jailed");
    });
}

The Jailor decision is your mod's networked state. Perfect Comms does not synchronize it.


Puppeteer

Behavior: during Tasks, a controlled living victim is muted and the local Puppeteer hears from the victim's position instead of their own.

Primitives: Gate and Listener Origin.

private static void RegisterPuppeteer()
{
    PerfectCommsApi.RegisterHostOption(Mod,
        new VoiceHostOption(
            "MutePuppeteered",
            "Puppeteer: Mute Controlled Victim",
            true));

    PerfectCommsApi.RegisterVoiceRule(Mod, ctx =>
        ctx.Phase == VoicePhaseKind.Tasks &&
        !ctx.IsDead &&
        ctx.GetOption("MutePuppeteered") &&
        ctx.Player.GetModifier<PuppeteerControlModifier>() != null
            ? VoiceRuleResult.Mute("Controlled")
            : VoiceRuleResult.Pass);

    PerfectCommsApi.RegisterListenerOrigin(Mod, local =>
    {
        // This state must become null outside the active task-phase control.
        var victim = MyRoles.VictimControlledBy(local);
        if (victim == null)
            return null;

        // Supply a real positive world-unit radius when vision should limit hearing.
        float radius = MyRoles.HearingRadiusAt(victim);
        return new VoiceListenerResult(
            (Vector2)victim.transform.position,
            radius,
            VoiceListenerMode.Replace);
    });
}

Listener-origin callbacks receive only the local PlayerControl; they do not receive a VoiceRuleContext and cannot call GetOption. This example therefore ties relocation directly to your synchronized control state instead of registering an option it cannot read. API 1.1 also does not treat LightRadius: -1 as inheritance: any non-positive value disables the vision-radius limit.

Use VoiceListenerMode.Additive when the local player should hear from both their own body and the supplied origin.


Medium

Behavior: during Tasks, a living Medium and dead players share a private, full-volume, two-way radio route. The host can turn the route Off or set it to Both.

Primitive: Channel.

private static void RegisterMedium()
{
    PerfectCommsApi.RegisterHostEnumOption(Mod,
        new VoiceHostEnumOption(
            "MediumVoice",
            "Medium: Ghost Voice",
            Default: 1,
            Choices: new[] { "Off", "Both" }));

    PerfectCommsApi.RegisterVoiceChannel(Mod, ctx =>
    {
        if (ctx.Phase != VoicePhaseKind.Tasks ||
            ctx.GetEnumOption("MediumVoice") != 1)
        {
            return null;
        }

        bool isLivingMedium =
            !ctx.IsDead &&
            ctx.Player.GetRole<MediumRole>() != null;
        bool isGhost = ctx.IsDead;

        if (!isLivingMedium && !isGhost)
            return null;

        // Use a per-Medium key if your mod permits multiple simultaneous Mediums.
        return new VoiceChannelResult(
            "medium-seance",
            TwoWay: true,
            Shape: VoiceAudioShape.Radio);
    });
}

API 1.1 does not provide dependable one-way routing with TwoWay: false, so this recipe intentionally exposes only Off and Both. A Proximity-shaped channel is spatial only when it also supplies an explicit Origin and is evaluated with a task listener position; otherwise it is flat.


Hidden-role mute

Behavior: a living hidden role cannot be heard during Tasks.

Primitive: Gate.

private static void RegisterHiddenVoice()
{
    PerfectCommsApi.RegisterHostOption(Mod,
        new VoiceHostOption(
            "MuteHidden",
            "Hidden Roles: Mute While Hidden",
            true));

    PerfectCommsApi.RegisterVoiceRule(Mod, ctx =>
    {
        if (ctx.Phase != VoicePhaseKind.Tasks ||
            ctx.IsDead ||
            !ctx.GetOption("MuteHidden"))
        {
            return VoiceRuleResult.Pass;
        }

        bool hidden =
            ctx.Player.GetModifier<SwoopModifier>() != null ||
            ctx.Player.GetModifier<VanishModifier>() != null;

        return hidden
            ? VoiceRuleResult.Mute("Hidden")
            : VoiceRuleResult.Pass;
    });
}

System-wide effect

Behavior: living speakers are muted while a synchronized system effect is active in Tasks or Meetings.

Primitive: Gate, using phase-scoped global gates.

private static void RegisterHackerJam()
{
    PerfectCommsApi.RegisterGlobalGate(
        Mod,
        VoicePhaseKind.Tasks,
        () => MySystems.JamActive,
        "Jammed");

    PerfectCommsApi.RegisterGlobalGate(
        Mod,
        VoicePhaseKind.Meeting,
        () => MySystems.JamActive,
        "Jammed");
}

The predicate must be cheap and must read state your mod already synchronizes.


Hide an identity-bearing speaker indicator

Behavior: when your role system says a speaker's identity is hidden, suppress that speaker's identity-bearing voice indicator.

Primitive: Overlay Privacy.

private static void RegisterOverlayPrivacy()
{
    PerfectCommsApi.RegisterHostOption(Mod,
        new VoiceHostOption(
            "HideHiddenVoiceIndicators",
            "Hidden Roles: Hide Voice Indicators",
            true));

    PerfectCommsApi.RegisterOverlaySpeakerRule(Mod, ctx =>
        ctx.GetOption("HideHiddenVoiceIndicators") &&
        MyRoles.IsVoiceIdentityHidden(ctx.Speaker)
            ? VoiceOverlaySpeakerResult.HideSource
            : VoiceOverlaySpeakerResult.Pass);
}

Overlay-privacy callbacks compose restrictively and fail private if they throw or return invalid data. Keep the predicate cheap and deterministic.


Notes carried by every example

  • Role and modifier checks are your mod's API; Perfect Comms does not discover or synchronize them.
  • Audio callbacks run at about 20 Hz per player. Overlay callbacks run at most once per rendered frame.
  • Return Pass or null whenever your behavior does not apply.
  • Audio callback exceptions fall back to a neutral result. Overlay privacy intentionally fails private.
  • Clients converge only when they have compatible integration code and your underlying role state is already synchronized.
  • Use unique option keys within your mod and register its tab once.

Next

Clone this wiki locally