BLOG · .NET · AI

A Pokémon mini-game built in 20 minutes with AI? It is possible!

Illustration: a Kernel Function and a Pokéball, the AI picks the function to run on its own

The other night, I set myself a challenge: build a Pokémon mini-game in .NET, with no database and no complex business logic, in under 30 minutes. The secret? Semantic Kernel and its Kernel Functions, which delegate all the logic to the AI.

The demo video

The game in action: the AI manages the team, fights and healing on its own (demo in French).

How does it work?

Simple: every game action (picking a Pokémon, fighting, healing...) is handled by a Kernel Function, an AI plugin that makes its decisions autonomously. No need to write hundreds of lines of conditions or manage a database: everything is handled by your Kernel Functions and the data models you defined.

The famous Kernel Functions live in a plugin I created and call directly from my service:

[KernelFunction("get_team")]
[Description("Shows the player's team of Pokémon with their stats.")]
public async Task<TeamResponseModel> GetTeamAsync()
{
    return new TeamResponseModel
    {
        Team = _team,
        MainPokemon = _mainPokemon?.Name ?? "No main Pokémon is set."
    };
}

[KernelFunction("heal_team")]
[Description("Heals every Pokémon in the team back to full health.")]
public async Task<string> HealTeamAsync()
{
    _team.ForEach(p => p.Health = p.MaxHealth);
    return "Pokémon healed.";
}

[KernelFunction("get_main_pokemon")]
[Description("Shows the current main Pokémon.")]
public async Task<string> GetMainPokemonAsync()
{
    if (_mainPokemon == null)
    {
        return "Not set";
    }

    return $"Main Pokémon: {_mainPokemon.Name}.";
}

With just a few well-named functions and clear descriptions (start_game, get_team, heal_team, set_main_pokemon, fight_wild_pokemon, catch_random_pokemon...), the AI figures out on its own which function to call based on the question asked.

One single route to talk to the game

On the API side, I use one single route. Whatever the action (starting a game, healing the team, showing the Pokémon...), I always call the same route, chatting naturally as if I were talking to a friend:

[HttpPost("play")]
public async Task<ActionResult<string>> PlayGame([FromBody] AskQuestionWithSessionCommand command)
{
    if (string.IsNullOrWhiteSpace(command.Question))
    {
        return BadRequest(new { Error = "The question cannot be empty." });
    }

    var result = await _gameService.PlayGameAsync(command);
    return Ok(result);
}

The service: where the magic happens

The service plugs in the plugin, lets the AI pick its own functions with FunctionChoiceBehavior.Auto(), and keeps the conversation history in a cache: the AI keeps the game context between calls and can play one continuous game.

public async Task<string> PlayGameAsync(AskQuestionWithSessionCommand command)
{
    // Bind the plugin that contains our kernel functions
    _kernel.Plugins.AddFromType<GamePlugin>("Game");

    // Get the AI chat service we work with
    var chatService = _kernel.GetRequiredService<IChatCompletionService>();

    // Prompt execution settings
    OpenAIPromptExecutionSettings settings = new()
    {
        // Let the AI pick which Kernel Function to use on its own
        FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(),
    };

    // Get or create a chat history for the current session
    var chatHistory = _cache.GetOrCreate(command.SessionId, entry =>
    {
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
        var newHistory = new ChatHistory();

        // System message defining the AI's role in the game
        newHistory.AddSystemMessage(
            "You are a Pokémon game master and your name is El Profesor. " +
            "You must help the player:\n" +
            "- Manage their team\n" +
            "- Fight wild Pokémon\n" +
            "- Catch new Pokémon."
        );

        return newHistory;
    });

    // Add the user's question to the history
    chatHistory.AddUserMessage(command.Question);

    // Generate an answer based on the context
    var result = await chatService.GetChatMessageContentAsync(
        chatHistory,
        executionSettings: settings,
        kernel: _kernel
    );

    // Add the AI's answer to the history
    chatHistory.AddAssistantMessage(result.ToString());

    return result.ToString();
}

Note the system message: it's what gives the AI its role ("El Profesor", the Pokémon game master).

The data models

The plugin declares the game state and the Pokémon pool, entirely in memory:

public class GamePlugin
{
    private static List<PokemonModel> _team = new();
    private static PokemonModel? _mainPokemon = null;
    private static readonly List<PokemonModel> _pokemonPool = new()
    {
        new PokemonModel { Name = "Pikachu", MaxHealth = 50, Attack = 25, Defense = 12, Type = "electric" },
        new PokemonModel { Name = "Bulbasaur", MaxHealth = 50, Attack = 20, Defense = 15, Type = "grass" },
        new PokemonModel { Name = "Charmander", MaxHealth = 60, Attack = 22, Defense = 10, Type = "fire" },
        new PokemonModel { Name = "Squirtle", MaxHealth = 50, Attack = 18, Defense = 18, Type = "water" },
        new PokemonModel { Name = "Dragonite", MaxHealth = 120, Attack = 40, Defense = 20, Type = "dragon" },
        // ... the rest of the pool
    };

    // kernel functions ...
}

public class PokemonModel
{
    public string Name { get; set; }
    public int Health { get; set; }
    public int MaxHealth { get; set; }
    public int Attack { get; set; }
    public int Defense { get; set; }
    public string Type { get; set; }
}

And the Kernel Function that starts a game:

[KernelFunction("start_game")]
[Description("Starts a new game by assigning 3 random Pokémon and sets a main Pokémon.")]
public async Task<string> StartGameAsync()
{
    var random = new Random();
    _team = _pokemonPool.OrderBy(x => random.Next()).Take(3).ToList();
    _team.ForEach(p => p.Health = p.MaxHealth);

    _mainPokemon = _team.First();

    return $"Team: {string.Join(", ", _team.Select(p => p.Name))} and main pokemon: {_mainPokemon.Name}";
}

The most interesting part?

And it doesn't just dumbly execute a function. It enriches every answer in a smart, surprising way: it knows which function to call AND how to enrich its reply, without me explicitly teaching it. It sometimes suggests actions nobody asked for, like what the player could do next.

My role? Three things only

Result: the AI can run an entire game on its own, always answering the player coherently.

Strengths of the approach

Ultra-fast development: one Kernel Function = a few lines of code + a natural-language prompt. The AI handles the rest.

Light, flexible architecture: no database, no complex backend.

Reusable plugins: Kernel Functions are like Lego. Build one once, reuse it everywhere.

Full control over the AI: each Kernel Function can be configured to fine-tune its behavior.

And you can go much further

In 20 minutes I had a working game, but that's only the beginning. With Semantic Kernel you can also create embeddings for smart search or automate complex tasks. That's exactly the kind of building block I later shipped to production on a real project: a RAG bot querying a company's entire knowledge base.

Semantic Kernel supports C#, Python and Java.

Takeaway: with a few well-chosen function names, a bit of code and simple data models, you get an AI able to figure out on its own how to act. The possibilities are endless.

This article comes from one of my LinkedIn posts (in French): join the discussion ↗