Skip to main content
5Stack provides comprehensive player moderation tools that allow Match Organizers and Administrators to enforce community standards and manage player behavior. The sanctions system supports multiple penalty types with flexible duration controls.

Overview

Player sanctions are disciplinary actions that restrict player participation or communication. The system tracks active and expired sanctions, abandoned matches, and provides real-time updates via GraphQL subscriptions.
Only users with Match Organizer role or higher can manage player sanctions.

Sanction Types

Ban

Complete RestrictionPlayer is not able to participate in any activity on the platform.

Mute

Voice RestrictionPlayer cannot use voice chat in game but can still play and use text chat.

Gag

Text RestrictionPlayer cannot use text chat in game but can still play and use voice chat.

Silence

Full Communication BanPlayer is both muted and gagged, unable to use any form of in-game communication.

Creating Sanctions

Sanction Interface

The sanction creation interface provides a streamlined workflow:
<template>
  <Popover>
    <PopoverTrigger as-child>
      <Button variant="outline">
        {{ $t("player.sanction.button") }}
        <ChevronDownIcon class="ml-2 h-4 w-4 text-muted-foreground" />
      </Button>
    </PopoverTrigger>
    <PopoverContent class="p-0" align="end">
      <Command v-model="sanctionType">
        <CommandList>
          <CommandGroup>
            <CommandItem
              v-for="(sanction, type) in sanctions"
              :key="type"
              :value="type"
              @click="sanctioningPlayer = true"
            >
              <div class="flex items-center gap-2">
                <component :is="sanction.icon" class="h-4 w-4" />
                <span class="font-medium capitalize">{{ type }}</span>
              </div>
              <p class="text-sm text-muted-foreground mt-1">
                {{ sanction.description }}
              </p>
            </CommandItem>
          </CommandGroup>
        </CommandList>
      </Command>
    </PopoverContent>
  </Popover>
</template>

Duration Options

Sanctions can be temporary or permanent:
const durations = [
  { label: "15 minutes", duration: 1000 * 60 * 15 },
  { label: "30 minutes", duration: 1000 * 60 * 30 },
  { label: "1 hour", duration: 1000 * 60 * 60 },
  { label: "1 day", duration: 1000 * 60 * 60 * 24 },
  { label: "1 week", duration: 1000 * 60 * 60 * 24 * 7 },
  { label: "1 month", duration: 1000 * 60 * 60 * 24 * 30 },
  { label: "Permanent", duration: 0 },
];

Creating a Sanction

async function sanctionPlayer() {
  if (!sanctionType) return;

  let remove_sanction_date: Date | null = null;

  const currentDate = new Date();
  if (form.values.duration && form.values.duration !== "0") {
    remove_sanction_date = new Date(
      currentDate.getTime() + parseInt(form.values.duration)
    );
  }

  await $apollo.mutate({
    mutation: generateMutation({
      insert_player_sanctions_one: [
        {
          object: {
            type: sanctionType,
            player_steam_id: player.steam_id,
            reason: form.values.reason,
            remove_sanction_date,
          },
        },
        {
          id: true,
        },
      ],
    }),
  });

  toast({
    title: `${sanctionType}ed ${player.name}`,
  });
}

Viewing Sanctions

Real-Time Subscription

Sanctions are loaded and updated in real-time:
apollo: {
  $subscribe: {
    player_sanctions: {
      query: typedGql("subscription")({
        player_sanctions: [
          {
            where: {
              player_steam_id: {
                _eq: $("playerId", "bigint!"),
              },
            },
          },
          {
            id: true,
            type: true,
            reason: true,
            created_at: true,
            remove_sanction_date: true,
          },
        ],
      }),
      variables: function (): { playerId: string } {
        return {
          playerId: this.playerId,
        };
      },
      result: function ({ data }: { data: any }) {
        this.sanctions = data.player_sanctions;
      },
    },
  },
}

Active vs Expired Sanctions

The system distinguishes between active and expired sanctions:
const activeSanctions = computed(() => {
  return sanctions.filter((sanction) => {
    if (sanction.remove_sanction_date) {
      return new Date(sanction.remove_sanction_date) > new Date();
    }
    return true; // Permanent sanctions are always active
  }).length;
});

Sanctions Display

The sanctions sheet shows a badge indicating the number of active sanctions:
<Button
  variant="ghost"
  size="sm"
  :class="{
    'text-destructive hover:text-destructive': activeSanctions > 0,
  }"
>
  <AlertTriangle class="h-3.5 w-3.5" />
  <span>{{ $t("player.sanctions.title") }}</span>
  <Badge
    v-if="activeSanctions > 0"
    variant="destructive"
    class="ml-0.5 h-4 px-1.5 text-xs"
  >
    {{ activeSanctions }}
  </Badge>
</Button>

Managing Sanctions

Editing Sanction Duration

Match Organizers can modify sanction expiration dates:
async function updateSanctionEndTime() {
  if (!editingSanction) return;

  let remove_sanction_date: Date | null = null;

  if (editDate && editTime) {
    const [hours, minutes] = editTime.split(":").map(Number);
    remove_sanction_date = new Date(
      Date.UTC(
        editDate.year,
        editDate.month - 1,
        editDate.day,
        hours,
        minutes
      )
    );
  }

  try {
    await $apollo.mutate({
      mutation: generateMutation({
        update_player_sanctions_by_pk: [
          {
            pk_columns: {
              id: editingSanction.id,
              created_at: editingSanction.created_at,
            },
            _set: {
              remove_sanction_date,
            },
          },
          {
            id: true,
            remove_sanction_date: true,
          },
        ],
      }),
    });

    toast({
      title: $t("player.sanctions.updated"),
    });
  } catch (error) {
    console.error("Failed to update sanction:", error);
    toast({
      title: $t("player.sanctions.update_failed"),
      variant: "destructive",
    });
  }
}

Removing Sanctions

Sanctions can be removed entirely:
async function confirmRemoveSanction() {
  if (!sanctionToDelete) return;

  try {
    await $apollo.mutate({
      mutation: generateMutation({
        delete_player_sanctions_by_pk: [
          {
            id: sanctionToDelete.id,
            created_at: sanctionToDelete.created_at,
          },
          {
            id: true,
          },
        ],
      }),
    });

    toast({
      title: $t("player.sanctions.removed"),
    });

    sanctionToDelete = null;
  } catch (error) {
    console.error("Failed to remove sanction:", error);
    toast({
      title: $t("player.sanctions.remove_failed"),
      variant: "destructive",
    });
  }
}

Abandoned Matches

The system tracks when players abandon matches before completion.

Tracking Abandoned Matches

abandoned_matches: {
  query: typedGql("subscription")({
    abandoned_matches: [
      {
        where: {
          steam_id: {
            _eq: $("playerId", "bigint!"),
          },
        },
        order_by: [
          {
            abandoned_at: order_by.desc,
          },
        ],
      },
      {
        id: true,
        steam_id: true,
        abandoned_at: true,
      },
    ],
  }),
  variables: function (): { playerId: string } {
    return {
      playerId: this.playerId,
    };
  },
  result: function ({ data }: { data: any }) {
    this.abandonedMatches = data.abandoned_matches;
  },
}

Abandoned Matches Display

Abandoned matches are shown in a separate tab with pagination:
const displayedAbandonedMatches = computed(() => {
  if (!abandonedMatches || abandonedMatches.length === 0) {
    return [];
  }
  const start = (abandonedMatchesPage - 1) * itemsPerPage;
  const end = start + itemsPerPage;
  return abandonedMatches.slice(start, end);
});

Removing Abandoned Match Records

async function confirmRemoveAbandonedMatch() {
  if (!abandonedMatchToDelete) return;

  try {
    await $apollo.mutate({
      mutation: generateMutation({
        delete_abandoned_matches_by_pk: [
          {
            id: abandonedMatchToDelete.id,
          },
          {
            id: true,
          },
        ],
      }),
    });

    toast({
      title: $t("player.sanctions.abandoned_removed"),
    });

    abandonedMatchToDelete = null;
  } catch (error) {
    console.error("Failed to remove abandoned match:", error);
    toast({
      title: $t("player.sanctions.abandoned_remove_failed"),
      variant: "destructive",
    });
  }
}

Permission Checks

All sanction management features check for proper permissions:
const canManageSanctions = computed(() => {
  return useAuthStore().isRoleAbove(e_player_roles_enum.match_organizer);
});
Edit and remove actions are only visible to Match Organizers and Administrators. Regular users and players can view their own sanctions but cannot modify them.

Best Practices

Always provide clear, specific reasons when applying sanctions. This helps with accountability and potential appeals.
Start with shorter durations for first-time offenses. Escalate to longer durations or permanent bans for repeat violations.
Periodically review active sanctions to ensure they remain appropriate and haven’t expired due to system issues.
Use mute/gag/silence for communication violations. Reserve bans for serious rule violations or repeated offenses.
Consider the number and frequency of abandoned matches. Single incidents may be technical issues rather than behavioral problems.

Roles & Permissions

Understand the permission system

Match Overview

Learn about match system

Player Profiles

View player information and history

Build docs developers (and LLMs) love