Skip to main content

External Legality Checkers

PKHeX allows you to register custom validation logic that runs after the standard legality checks. This is done through the IExternalLegalityChecker interface.

Creating a Custom Checker

Step 1: Implement the Interface

using PKHeX.Core;

public class MyCustomChecker : IExternalLegalityChecker
{
    // Unique identifier for this checker
    public ushort Identity => 1000; // Use a unique number
    
    // Display name
    public string Name => "My Custom Validator";
    
    // Perform validation
    public void Verify(LegalityAnalysis analysis)
    {
        // Your custom validation logic here
        var pk = analysis.Entity;
        
        // Example: Check if nickname contains profanity
        if (ContainsProfanity(pk.Nickname))
        {
            var result = new CheckResult
            {
                Judgement = Severity.Invalid,
                Identifier = CheckIdentifier.Nickname,
                Result = LegalityCheckResultCode.External,
                Value = Identity // Store our identity for localization
            };
            
            analysis.AddLine(result);
        }
    }
    
    // Provide localized messages
    public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
    {
        // Return custom message for your results
        return "Nickname contains inappropriate content.";
    }
    
    private bool ContainsProfanity(string text)
    {
        // Your implementation
        return false;
    }
}

Step 2: Register the Checker

using PKHeX.Core;

public class Program
{
    static Program()
    {
        // Register at application startup
        var checker = new MyCustomChecker();
        ExternalLegalityCheck.RegisterChecker(checker);
    }
    
    static void Main()
    {
        // Now all legality checks will include your custom validator
        var pk = LoadPokemon();
        var analysis = new LegalityAnalysis(pk);
        
        // Your checker ran automatically
        if (!analysis.Valid)
        {
            Console.WriteLine("Validation failed (including custom checks)");
        }
    }
}

Example: Competitive Battle Validator

Validate Pokémon for competitive battle format rules:
using PKHeX.Core;
using System.Collections.Generic;
using System.Linq;

public class CompetitiveBattleChecker : IExternalLegalityChecker
{
    public ushort Identity => 2000;
    public string Name => "Competitive Battle Validator";
    
    private static readonly HashSet<ushort> BannedSpecies = new()
    {
        150, // Mewtwo
        382, // Kyogre
        383, // Groudon
        384, // Rayquaza
        // ... etc
    };
    
    private static readonly HashSet<ushort> BannedAbilities = new()
    {
        95,  // Moody
        // ... etc
    };
    
    private static readonly HashSet<ushort> BannedMoves = new()
    {
        560, // Dark Void
        // ... etc
    };
    
    public void Verify(LegalityAnalysis analysis)
    {
        var pk = analysis.Entity;
        
        // Check for banned species
        if (BannedSpecies.Contains(pk.Species))
        {
            AddInvalid(analysis, CheckIdentifier.Encounter,
                "Species is banned in competitive play.");
        }
        
        // Check for banned abilities
        if (BannedAbilities.Contains((ushort)pk.Ability))
        {
            AddInvalid(analysis, CheckIdentifier.Ability,
                "Ability is banned in competitive play.");
        }
        
        // Check for banned moves
        var moves = new[] { pk.Move1, pk.Move2, pk.Move3, pk.Move4 };
        foreach (var move in moves)
        {
            if (BannedMoves.Contains(move))
            {
                AddInvalid(analysis, CheckIdentifier.CurrentMove,
                    $"Move {move} is banned in competitive play.");
            }
        }
        
        // Check level restriction (e.g., Level 50 for VGC)
        if (pk.CurrentLevel > 50)
        {
            AddInvalid(analysis, CheckIdentifier.Level,
                "Level must be 50 or lower for VGC.");
        }
    }
    
    private void AddInvalid(LegalityAnalysis analysis, CheckIdentifier identifier, string message)
    {
        var result = new CheckResult
        {
            Judgement = Severity.Invalid,
            Identifier = identifier,
            Result = LegalityCheckResultCode.External,
            Value = Identity
        };
        
        analysis.AddLine(result);
        
        // Store message for localization
        _messages[identifier] = message;
    }
    
    private Dictionary<CheckIdentifier, string> _messages = new();
    
    public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
    {
        if (_messages.TryGetValue(chk.Identifier, out var message))
            return message;
        
        return "Failed competitive battle validation.";
    }
}

Example: Event Distribution Validator

Validate event Pokémon against distribution dates:
using PKHeX.Core;
using System;

public class EventDateChecker : IExternalLegalityChecker
{
    public ushort Identity => 3000;
    public string Name => "Event Date Validator";
    
    public void Verify(LegalityAnalysis analysis)
    {
        var pk = analysis.Entity;
        var encounter = analysis.EncounterMatch;
        
        // Only check Mystery Gifts
        if (encounter is not MysteryGift gift)
            return;
        
        // Check if the met date is within distribution window
        if (pk is IEncounterDate dated)
        {
            var metDate = new DateTime(dated.MetYear + 2000, dated.MetMonth, dated.MetDay);
            
            // Example: Check against known distribution dates
            // You would load this from a database
            var distributionStart = new DateTime(2023, 1, 1);
            var distributionEnd = new DateTime(2023, 12, 31);
            
            if (metDate < distributionStart || metDate > distributionEnd)
            {
                var result = new CheckResult
                {
                    Judgement = Severity.Invalid,
                    Identifier = CheckIdentifier.Encounter,
                    Result = LegalityCheckResultCode.DateOutsideDistributionWindow,
                    Value = Identity
                };
                
                analysis.AddLine(result);
            }
        }
    }
    
    public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
    {
        return "Met date is outside the event distribution window.";
    }
}

Example: Shiny Lock Validator

Add additional shiny lock validation:
using PKHeX.Core;
using System.Collections.Generic;

public class ShinyLockChecker : IExternalLegalityChecker
{
    public ushort Identity => 4000;
    public string Name => "Shiny Lock Validator";
    
    // Species that are shiny locked in certain games
    private static readonly Dictionary<ushort, EntityContext> ShinyLocks = new()
    {
        { 249, EntityContext.Gen7 },    // Lugia in USUM
        { 250, EntityContext.Gen7 },    // Ho-Oh in USUM
        { 716, EntityContext.Gen7 },    // Xerneas in USUM
        { 717, EntityContext.Gen7 },    // Yveltal in USUM
        // ... etc
    };
    
    public void Verify(LegalityAnalysis analysis)
    {
        var pk = analysis.Entity;
        
        if (!pk.IsShiny)
            return; // Not shiny, no problem
        
        var encounter = analysis.EncounterMatch;
        
        // Check if this species has a shiny lock
        if (ShinyLocks.TryGetValue(pk.Species, out var lockedContext))
        {
            if (encounter.Context == lockedContext)
            {
                var result = new CheckResult
                {
                    Judgement = Severity.Invalid,
                    Identifier = CheckIdentifier.Shiny,
                    Result = LegalityCheckResultCode.External,
                    Value = Identity
                };
                
                analysis.AddLine(result);
            }
        }
    }
    
    public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
    {
        return "This Pokémon is shiny locked in its origin game.";
    }
}

Example: Ribbon Validator

Validate that ribbon combinations are possible:
using PKHeX.Core;

public class RibbonLogicChecker : IExternalLegalityChecker
{
    public ushort Identity => 5000;
    public string Name => "Ribbon Logic Validator";
    
    public void Verify(LegalityAnalysis analysis)
    {
        var pk = analysis.Entity;
        
        // Example: Champion Ribbon requires beating Elite Four
        // but Partner Ribbon is only for starter Pokémon
        if (pk is IRibbonSetCommon6 ribbons)
        {
            if (ribbons.RibbonChampionKalos && ribbons.RibbonPartner)
            {
                // This combination might be suspicious
                var result = new CheckResult
                {
                    Judgement = Severity.Fishy,
                    Identifier = CheckIdentifier.Ribbon,
                    Result = LegalityCheckResultCode.External,
                    Value = Identity
                };
                
                analysis.AddLine(result);
            }
        }
        
        // Check for impossible ribbon counts
        if (pk is IRibbonSetCommon8 ribbons8)
        {
            int ribbonCount = CountRibbons(ribbons8);
            
            // Example: No single Pokémon should have every ribbon
            if (ribbonCount > 50) // Arbitrary threshold
            {
                var result = new CheckResult
                {
                    Judgement = Severity.Fishy,
                    Identifier = CheckIdentifier.Ribbon,
                    Result = LegalityCheckResultCode.External,
                    Value = Identity
                };
                
                analysis.AddLine(result);
            }
        }
    }
    
    private int CountRibbons(object ribbons)
    {
        // Count non-zero ribbon properties
        int count = 0;
        var type = ribbons.GetType();
        
        foreach (var prop in type.GetProperties())
        {
            if (prop.PropertyType == typeof(bool))
            {
                if ((bool)prop.GetValue(ribbons))
                    count++;
            }
        }
        
        return count;
    }
    
    public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
    {
        return "Ribbon combination is unusual or impossible.";
    }
}

Accessing Analysis Data

Your checker has full access to the analysis:
public void Verify(LegalityAnalysis analysis)
{
    // The Pokémon being checked
    var pk = analysis.Entity;
    
    // Personal info (species data)
    var personal = analysis.PersonalInfo;
    
    // Matched encounter
    var encounter = analysis.EncounterMatch;
    var original = analysis.EncounterOriginal;
    
    // Where it came from
    var origin = analysis.SlotOrigin;
    
    // Detailed info
    var info = analysis.Info;
    var generation = info.Generation;
    var moves = info.Moves;
    var relearn = info.Relearn;
    var evolutions = info.EvoChainsAllGens;
    var pidiv = info.PIDIV;
    
    // Existing check results
    var results = analysis.Results;
    
    // You can check what other verifiers found
    var abilityCheck = results.FirstOrDefault(r => 
        r.Identifier == CheckIdentifier.Ability);
    
    if (abilityCheck.Valid)
    {
        // Ability passed standard checks
    }
}

Creating Check Results

public void Verify(LegalityAnalysis analysis)
{
    // Invalid result
    var invalid = new CheckResult
    {
        Judgement = Severity.Invalid,
        Identifier = CheckIdentifier.Misc,
        Result = LegalityCheckResultCode.External,
        Value = Identity // Your checker ID
    };
    
    // Fishy/suspicious result
    var fishy = new CheckResult
    {
        Judgement = Severity.Fishy,
        Identifier = CheckIdentifier.Trainer,
        Result = LegalityCheckResultCode.External,
        Value = Identity
    };
    
    // Valid with custom message
    var valid = new CheckResult
    {
        Judgement = Severity.Valid,
        Identifier = CheckIdentifier.Encounter,
        Result = LegalityCheckResultCode.External,
        Value = Identity
    };
    
    // Add results
    analysis.AddLine(invalid);
    analysis.AddLine(fishy);
    analysis.AddLine(valid);
}

Including Numeric Arguments

public void Verify(LegalityAnalysis analysis)
{
    var pk = analysis.Entity;
    
    // Single 32-bit argument
    var result1 = new CheckResult
    {
        Judgement = Severity.Invalid,
        Identifier = CheckIdentifier.Level,
        Result = LegalityCheckResultCode.External,
        Value = 50 // Max level allowed
    };
    
    // Two 16-bit arguments
    var result2 = new CheckResult
    {
        Judgement = Severity.Invalid,
        Identifier = CheckIdentifier.CurrentMove,
        Result = LegalityCheckResultCode.External,
        Argument = 2,    // Move slot
        Argument2 = 123  // Move ID
    };
    
    analysis.AddLine(result1);
    analysis.AddLine(result2);
}

Localization

Provide messages in multiple languages:
using System.Collections.Generic;

public class MultilingualChecker : IExternalLegalityChecker
{
    public ushort Identity => 6000;
    public string Name => "Multilingual Validator";
    
    private Dictionary<string, Dictionary<string, string>> _messages = new()
    {
        ["en"] = new()
        {
            ["nickname_invalid"] = "Nickname contains invalid characters.",
            ["trainer_suspicious"] = "Trainer name is suspicious."
        },
        ["es"] = new()
        {
            ["nickname_invalid"] = "El apodo contiene caracteres no válidos.",
            ["trainer_suspicious"] = "El nombre del entrenador es sospechoso."
        },
        ["ja"] = new()
        {
            ["nickname_invalid"] = "ニックネームに無効な文字が含まれています。",
            ["trainer_suspicious"] = "トレーナー名が疑わしいです。"
        }
    };
    
    public void Verify(LegalityAnalysis analysis)
    {
        // Your validation logic
    }
    
    public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
    {
        // Get current language from localization settings
        var language = localization.Language.ToString().ToLower();
        
        // Determine which message to return based on context
        var messageKey = chk.Identifier switch
        {
            CheckIdentifier.Nickname => "nickname_invalid",
            CheckIdentifier.Trainer => "trainer_suspicious",
            _ => "unknown"
        };
        
        // Return localized message
        if (_messages.TryGetValue(language, out var messages))
        {
            if (messages.TryGetValue(messageKey, out var message))
                return message;
        }
        
        // Fallback to English
        return _messages["en"][messageKey];
    }
}

Unregistering Checkers

var checker = new MyCustomChecker();

// Register
ExternalLegalityCheck.RegisterChecker(checker);

// Later, if needed, unregister
ExternalLegalityCheck.UnregisterChecker(checker);

Best Practices

Choose identity numbers that won’t conflict with other checkers. Use a range like 1000-9999 for custom checkers.
public ushort Identity => 1234; // Pick a unique number
PKHeX already validates most standard legality. Focus on:
  • Game-specific rules not in PKHeX
  • Competitive format restrictions
  • Community-specific rules
  • Additional sanity checks
  • Severity.Invalid: Definitively illegal
  • Severity.Fishy: Suspicious but technically valid
  • Severity.Valid: Passed your custom check (rare)
Your Localize method should return clear, actionable messages.
public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
{
    return "Move is banned in VGC 2024 Series 1.";
}

Complete Example

using PKHeX.Core;
using System;
using System.Collections.Generic;

public class CustomValidator : IExternalLegalityChecker
{
    public ushort Identity => 7000;
    public string Name => "Custom Validator";
    
    private List<string> _errors = new();
    
    public void Verify(LegalityAnalysis analysis)
    {
        _errors.Clear();
        
        var pk = analysis.Entity;
        
        // Check 1: Validate nickname length
        if (pk.Nickname.Length > 12)
        {
            _errors.Add("Nickname too long");
            AddInvalid(analysis, CheckIdentifier.Nickname);
        }
        
        // Check 2: Validate level
        if (pk.CurrentLevel > 100)
        {
            _errors.Add("Level exceeds maximum");
            AddInvalid(analysis, CheckIdentifier.Level);
        }
        
        // Check 3: Validate moves
        var moves = new[] { pk.Move1, pk.Move2, pk.Move3, pk.Move4 };
        var duplicates = moves.Where(m => m != 0)
                               .GroupBy(m => m)
                               .Where(g => g.Count() > 1);
        
        if (duplicates.Any())
        {
            _errors.Add("Duplicate moves detected");
            AddInvalid(analysis, CheckIdentifier.CurrentMove);
        }
    }
    
    private void AddInvalid(LegalityAnalysis analysis, CheckIdentifier identifier)
    {
        var result = new CheckResult
        {
            Judgement = Severity.Invalid,
            Identifier = identifier,
            Result = LegalityCheckResultCode.External,
            Value = Identity
        };
        
        analysis.AddLine(result);
    }
    
    public string Localize(CheckResult chk, LegalityLocalizationSet localization, LegalityAnalysis data)
    {
        if (_errors.Count > 0)
            return string.Join("; ", _errors);
        
        return "Custom validation failed.";
    }
}

// Usage
public class Program
{
    static void Main()
    {
        // Register at startup
        ExternalLegalityCheck.RegisterChecker(new CustomValidator());
        
        // Now use LegalityAnalysis normally
        var pk = LoadPokemon();
        var analysis = new LegalityAnalysis(pk);
        
        if (!analysis.Valid)
        {
            foreach (var result in analysis.Results.Where(r => !r.Valid))
            {
                Console.WriteLine($"{result.Identifier}: {result.Result}");
            }
        }
    }
}

Next Steps

Overview

Return to legality system overview

Running Checks

Learn how to perform legality checks

Build docs developers (and LLMs) love