diff --git a/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs b/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs
new file mode 100644
index 0000000..ae8ba56
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs
@@ -0,0 +1,200 @@
+#nullable disable
+using CommandLine;
+using System.Collections.Immutable;
+
+namespace EllieBot.Modules.Games.Common.Acrophobia;
+
+public sealed class AcrophobiaGame : IDisposable
+{
+    public enum Phase
+    {
+        Submission,
+        Voting,
+        Ended
+    }
+
+    public enum UserInputResult
+    {
+        Submitted,
+        SubmissionFailed,
+        Voted,
+        VotingFailed,
+        Failed
+    }
+
+    public event Func<AcrophobiaGame, Task> OnStarted = delegate { return Task.CompletedTask; };
+
+    public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnVotingStarted =
+        delegate { return Task.CompletedTask; };
+
+    public event Func<string, Task> OnUserVoted = delegate { return Task.CompletedTask; };
+
+    public event Func<AcrophobiaGame, ImmutableArray<KeyValuePair<AcrophobiaUser, int>>, Task> OnEnded = delegate
+    {
+        return Task.CompletedTask;
+    };
+
+    public Phase CurrentPhase { get; private set; } = Phase.Submission;
+    public ImmutableArray<char> StartingLetters { get; private set; }
+    public Options Opts { get; }
+
+    private readonly Dictionary<AcrophobiaUser, int> _submissions = new();
+    private readonly SemaphoreSlim _locker = new(1, 1);
+    private readonly EllieRandom _rng;
+
+    private readonly HashSet<ulong> _usersWhoVoted = [];
+
+    public AcrophobiaGame(Options options)
+    {
+        Opts = options;
+        _rng = new();
+        InitializeStartingLetters();
+    }
+
+    public async Task Run()
+    {
+        await OnStarted(this);
+        await Task.Delay(Opts.SubmissionTime * 1000);
+        await _locker.WaitAsync();
+        try
+        {
+            if (_submissions.Count == 0)
+            {
+                CurrentPhase = Phase.Ended;
+                await OnVotingStarted(this, ImmutableArray.Create<KeyValuePair<AcrophobiaUser, int>>());
+                return;
+            }
+
+            if (_submissions.Count == 1)
+            {
+                CurrentPhase = Phase.Ended;
+                await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
+                return;
+            }
+
+            CurrentPhase = Phase.Voting;
+
+            await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray());
+        }
+        finally { _locker.Release(); }
+
+        await Task.Delay(Opts.VoteTime * 1000);
+        await _locker.WaitAsync();
+        try
+        {
+            CurrentPhase = Phase.Ended;
+            await OnEnded(this, _submissions.ToArray().ToImmutableArray());
+        }
+        finally { _locker.Release(); }
+    }
+
+    private void InitializeStartingLetters()
+    {
+        var wordCount = _rng.Next(3, 6);
+
+        var lettersArr = new char[wordCount];
+
+        for (var i = 0; i < wordCount; i++)
+        {
+            var randChar = (char)_rng.Next(65, 91);
+            lettersArr[i] = randChar == 'X' ? (char)_rng.Next(65, 88) : randChar;
+        }
+
+        StartingLetters = lettersArr.ToImmutableArray();
+    }
+
+    public async Task<bool> UserInput(ulong userId, string userName, string input)
+    {
+        var user = new AcrophobiaUser(userId, userName, input.ToLowerInvariant().ToTitleCase());
+
+        await _locker.WaitAsync();
+        try
+        {
+            switch (CurrentPhase)
+            {
+                case Phase.Submission:
+                    if (_submissions.ContainsKey(user) || !IsValidAnswer(input))
+                        break;
+
+                    _submissions.Add(user, 0);
+                    return true;
+                case Phase.Voting:
+                    AcrophobiaUser toVoteFor;
+                    if (!int.TryParse(input, out var index)
+                        || --index < 0
+                        || index >= _submissions.Count
+                        || (toVoteFor = _submissions.ToArray()[index].Key).UserId == user.UserId
+                        || !_usersWhoVoted.Add(userId))
+                        break;
+                    ++_submissions[toVoteFor];
+                    _ = Task.Run(() => OnUserVoted(userName));
+                    return true;
+            }
+
+            return false;
+        }
+        finally
+        {
+            _locker.Release();
+        }
+    }
+
+    private bool IsValidAnswer(string input)
+    {
+        input = input.ToUpperInvariant();
+
+        var inputWords = input.Split(' ');
+
+        if (inputWords.Length
+            != StartingLetters.Length) // number of words must be the same as the number of the starting letters
+            return false;
+
+        for (var i = 0; i < StartingLetters.Length; i++)
+        {
+            var letter = StartingLetters[i];
+
+            if (!inputWords[i]
+                    .StartsWith(letter.ToString(), StringComparison.InvariantCulture)) // all first letters must match
+                return false;
+        }
+
+        return true;
+    }
+
+    public void Dispose()
+    {
+        CurrentPhase = Phase.Ended;
+        OnStarted = null;
+        OnEnded = null;
+        OnUserVoted = null;
+        OnVotingStarted = null;
+        _usersWhoVoted.Clear();
+        _submissions.Clear();
+        _locker.Dispose();
+    }
+
+    public class Options : IEllieCommandOptions
+    {
+        [Option('s',
+            "submission-time",
+            Required = false,
+            Default = 60,
+            HelpText = "Time after which the submissions are closed and voting starts.")]
+        public int SubmissionTime { get; set; } = 60;
+
+        [Option('v',
+            "vote-time",
+            Required = false,
+            Default = 60,
+            HelpText = "Time after which the voting is closed and the winner is declared.")]
+        public int VoteTime { get; set; } = 30;
+
+        public void NormalizeOptions()
+        {
+            if (SubmissionTime is < 15 or > 300)
+                SubmissionTime = 60;
+            if (VoteTime is < 15 or > 120)
+                VoteTime = 30;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs b/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs
new file mode 100644
index 0000000..2de2917
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs
@@ -0,0 +1,22 @@
+#nullable disable
+namespace EllieBot.Modules.Games.Common.Acrophobia;
+
+public class AcrophobiaUser
+{
+    public string UserName { get; }
+    public ulong UserId { get; }
+    public string Input { get; }
+
+    public AcrophobiaUser(ulong userId, string userName, string input)
+    {
+        UserName = userName;
+        UserId = userId;
+        Input = input;
+    }
+
+    public override int GetHashCode()
+        => UserId.GetHashCode();
+
+    public override bool Equals(object obj)
+        => obj is AcrophobiaUser x ? x.UserId == UserId : false;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs b/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs
new file mode 100644
index 0000000..30defba
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs
@@ -0,0 +1,140 @@
+#nullable disable
+using EllieBot.Modules.Games.Common.Acrophobia;
+using EllieBot.Modules.Games.Services;
+using System.Collections.Immutable;
+
+namespace EllieBot.Modules.Games;
+
+public partial class Games
+{
+    [Group]
+    public partial class AcropobiaCommands : EllieModule<GamesService>
+    {
+        private readonly DiscordSocketClient _client;
+
+        public AcropobiaCommands(DiscordSocketClient client)
+            => _client = client;
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [EllieOptions<AcrophobiaGame.Options>]
+        public async Task Acrophobia(params string[] args)
+        {
+            var (options, _) = OptionsParser.ParseFrom(new AcrophobiaGame.Options(), args);
+            var channel = (ITextChannel)ctx.Channel;
+
+            var game = new AcrophobiaGame(options);
+            if (_service.AcrophobiaGames.TryAdd(channel.Id, game))
+            {
+                try
+                {
+                    game.OnStarted += Game_OnStarted;
+                    game.OnEnded += Game_OnEnded;
+                    game.OnVotingStarted += Game_OnVotingStarted;
+                    game.OnUserVoted += Game_OnUserVoted;
+                    _client.MessageReceived += ClientMessageReceived;
+                    await game.Run();
+                }
+                finally
+                {
+                    _client.MessageReceived -= ClientMessageReceived;
+                    _service.AcrophobiaGames.TryRemove(channel.Id, out game);
+                    game?.Dispose();
+                }
+            }
+            else
+                await Response().Error(strs.acro_running).SendAsync();
+
+            Task ClientMessageReceived(SocketMessage msg)
+            {
+                if (msg.Channel.Id != ctx.Channel.Id)
+                    return Task.CompletedTask;
+
+                _ = Task.Run(async () =>
+                {
+                    try
+                    {
+                        var success = await game.UserInput(msg.Author.Id, msg.Author.ToString(), msg.Content);
+                        if (success)
+                            await msg.DeleteAsync();
+                    }
+                    catch { }
+                });
+
+                return Task.CompletedTask;
+            }
+        }
+
+        private Task Game_OnStarted(AcrophobiaGame game)
+        {
+            var embed = _sender.CreateEmbed()
+                           .WithOkColor()
+                           .WithTitle(GetText(strs.acrophobia))
+                           .WithDescription(
+                               GetText(strs.acro_started(Format.Bold(string.Join(".", game.StartingLetters)))))
+                           .WithFooter(GetText(strs.acro_started_footer(game.Opts.SubmissionTime)));
+
+            return Response().Embed(embed).SendAsync();
+        }
+
+        private Task Game_OnUserVoted(string user)
+            => Response().Confirm(GetText(strs.acrophobia), GetText(strs.acro_vote_cast(Format.Bold(user)))).SendAsync();
+
+        private async Task Game_OnVotingStarted(
+            AcrophobiaGame game,
+            ImmutableArray<KeyValuePair<AcrophobiaUser, int>> submissions)
+        {
+            if (submissions.Length == 0)
+            {
+                await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_ended_no_sub)).SendAsync();
+                return;
+            }
+
+            if (submissions.Length == 1)
+            {
+                await Response().Embed(_sender.CreateEmbed()
+                                                .WithOkColor()
+                                                .WithDescription(GetText(
+                                                    strs.acro_winner_only(
+                                                        Format.Bold(submissions.First().Key.UserName))))
+                                                .WithFooter(submissions.First().Key.Input)).SendAsync();
+                return;
+            }
+
+
+            var i = 0;
+            var embed = _sender.CreateEmbed()
+                           .WithOkColor()
+                           .WithTitle(GetText(strs.acrophobia) + " - " + GetText(strs.submissions_closed))
+                           .WithDescription(GetText(strs.acro_nym_was(
+                               Format.Bold(string.Join(".", game.StartingLetters))
+                               + "\n"
+                               + $@"--
+{submissions.Aggregate("", (agg, cur) => agg + $"`{++i}.` **{cur.Key.Input}**\n")}
+--")))
+                           .WithFooter(GetText(strs.acro_vote));
+
+            await Response().Embed(embed).SendAsync();
+        }
+
+        private async Task Game_OnEnded(AcrophobiaGame game, ImmutableArray<KeyValuePair<AcrophobiaUser, int>> votes)
+        {
+            if (!votes.Any() || votes.All(x => x.Value == 0))
+            {
+                await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_no_votes_cast)).SendAsync();
+                return;
+            }
+
+            var table = votes.OrderByDescending(v => v.Value);
+            var winner = table.First();
+            var embed = _sender.CreateEmbed()
+                           .WithOkColor()
+                           .WithTitle(GetText(strs.acrophobia))
+                           .WithDescription(GetText(strs.acro_winner(Format.Bold(winner.Key.UserName),
+                               Format.Bold(winner.Value.ToString()))))
+                           .WithFooter(winner.Key.Input);
+
+            await Response().Embed(embed).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/ChatterBotCommands.cs b/src/EllieBot/Modules/Games/ChatterBot/ChatterBotCommands.cs
new file mode 100644
index 0000000..194cf79
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/ChatterBotCommands.cs
@@ -0,0 +1,35 @@
+#nullable disable
+using EllieBot.Modules.Games.Services;
+
+namespace EllieBot.Modules.Games;
+
+public partial class Games
+{
+    [Group]
+    public partial class ChatterBotCommands : EllieModule<ChatterBotService>
+    {
+        private readonly DbService _db;
+
+        public ChatterBotCommands(DbService db)
+            => _db = db;
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        public async Task CleverBot()
+        {
+            var channel = (ITextChannel)ctx.Channel;
+
+            var newState = await _service.ToggleChatterBotAsync(ctx.Guild.Id);
+
+            if (!newState)
+            {
+                await Response().Confirm(strs.chatbot_disabled).SendAsync();
+                return;
+            }
+
+            await Response().Confirm(strs.chatbot_enabled).SendAsync();
+            
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/ChatterBotService.cs b/src/EllieBot/Modules/Games/ChatterBot/ChatterBotService.cs
new file mode 100644
index 0000000..b1ca50c
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/ChatterBotService.cs
@@ -0,0 +1,239 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Games.Common;
+using EllieBot.Modules.Games.Common.ChatterBot;
+using EllieBot.Modules.Patronage;
+using EllieBot.Modules.Permissions;
+
+namespace EllieBot.Modules.Games.Services;
+
+public class ChatterBotService : IExecOnMessage
+{
+    private ConcurrentDictionary<ulong, Lazy<IChatterBotSession>> ChatterBotGuilds { get; }
+
+    public int Priority
+        => 1;
+
+    private readonly DiscordSocketClient _client;
+    private readonly IPermissionChecker _perms;
+    private readonly IBotCredentials _creds;
+    private readonly IHttpClientFactory _httpFactory;
+    private readonly GamesConfigService _gcs;
+    private readonly IMessageSenderService _sender;
+    private readonly DbService _db;
+    public readonly IPatronageService _ps;
+
+    public ChatterBotService(
+        DiscordSocketClient client,
+        IPermissionChecker perms,
+        IBot bot,
+        IPatronageService ps,
+        IHttpClientFactory factory,
+        IBotCredentials creds,
+        GamesConfigService gcs,
+        IMessageSenderService sender,
+        DbService db)
+    {
+        _client = client;
+        _perms = perms;
+        _creds = creds;
+        _sender = sender;
+        _db = db;
+        _httpFactory = factory;
+        _perms = perms;
+        _gcs = gcs;
+        _ps = ps;
+
+        ChatterBotGuilds = new(bot.AllGuildConfigs
+                                  .Where(gc => gc.CleverbotEnabled)
+                                  .ToDictionary(gc => gc.GuildId,
+                                      _ => new Lazy<IChatterBotSession>(() => CreateSession(), true)));
+    }
+
+    public IChatterBotSession CreateSession()
+    {
+        switch (_gcs.Data.ChatBot)
+        {
+            case ChatBotImplementation.Cleverbot:
+                if (!string.IsNullOrWhiteSpace(_creds.CleverbotApiKey))
+                    return new OfficialCleverbotSession(_creds.CleverbotApiKey, _httpFactory);
+
+                Log.Information("Cleverbot will not work as the api key is missing");
+                return null;
+            case ChatBotImplementation.OpenAi:
+                var data = _gcs.Data;
+                if (!string.IsNullOrWhiteSpace(_creds.Gpt3ApiKey))
+                    return new OpenAiApiSession(
+                        data.ChatGpt.ApiUrl,
+                        _creds.Gpt3ApiKey,
+                        data.ChatGpt.ModelName,
+                        data.ChatGpt.ChatHistory,
+                        data.ChatGpt.MaxTokens,
+                        data.ChatGpt.MinTokens,
+                        data.ChatGpt.PersonalityPrompt,
+                        _client.CurrentUser.Username,
+                        _httpFactory);
+
+                Log.Information("Openai Api will likely not work as the api key is missing");
+                return null;
+            default:
+                return null;
+        }
+    }
+
+    public IChatterBotSession GetOrCreateSession(ulong guildId)
+    {
+        if (ChatterBotGuilds.TryGetValue(guildId, out var lazyChatBot))
+            return lazyChatBot.Value;
+
+        lazyChatBot = new(() => CreateSession(), true);
+        ChatterBotGuilds.TryAdd(guildId, lazyChatBot);
+        return lazyChatBot.Value;
+    }
+
+    public string PrepareMessage(IUserMessage msg)
+    {
+        var nadekoId = _client.CurrentUser.Id;
+        var normalMention = $"<@{nadekoId}> ";
+        var nickMention = $"<@!{nadekoId}> ";
+        string message;
+        if (msg.Content.StartsWith(normalMention, StringComparison.InvariantCulture))
+            message = msg.Content[normalMention.Length..].Trim();
+        else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture))
+            message = msg.Content[nickMention.Length..].Trim();
+        else if (msg.ReferencedMessage?.Author.Id == nadekoId)
+            message = msg.Content;
+        else
+            return null;
+
+        return message;
+    }
+
+    public async Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg)
+    {
+        if (guild is not SocketGuild sg)
+            return false;
+
+        var channel = usrMsg.Channel as ITextChannel;
+        if (channel is null)
+            return false;
+
+        if (!ChatterBotGuilds.TryGetValue(channel.Guild.Id, out var lazyChatBot))
+            return false;
+
+        var chatBot = lazyChatBot.Value;
+        var message = PrepareMessage(usrMsg);
+        if (message is null)
+            return false;
+
+        return await RunChatterBot(sg, usrMsg, channel, chatBot, message);
+    }
+
+    public async Task<bool> RunChatterBot(
+        SocketGuild guild,
+        IUserMessage usrMsg,
+        ITextChannel channel,
+        IChatterBotSession chatBot,
+        string message)
+    {
+        try
+        {
+            var res = await _perms.CheckPermsAsync(guild,
+                usrMsg.Channel,
+                usrMsg.Author,
+                CleverBotResponseStr.CLEVERBOT_RESPONSE,
+                CleverBotResponseStr.CLEVERBOT_RESPONSE);
+
+            if (!res.IsAllowed)
+                return false;
+
+            if (!await _ps.LimitHitAsync(LimitedFeatureName.ChatBot, usrMsg.Author.Id, 2048 / 2))
+            {
+                // limit exceeded
+                return false;
+            }
+
+            _ = channel.TriggerTypingAsync();
+            var response = await chatBot.Think(message, usrMsg.Author.ToString());
+
+            if (response.TryPickT0(out var result, out var error))
+            {
+                // calculate the diff in case we overestimated user's usage
+                var inTokens = (result.TokensIn - 2048) / 2;
+
+                // add the output tokens to the limit
+                await _ps.LimitForceHit(LimitedFeatureName.ChatBot,
+                    usrMsg.Author.Id,
+                    (inTokens) + (result.TokensOut / 2 * 3));
+
+                await _sender.Response(channel)
+                             .Confirm(result.Text)
+                             .SendAsync();
+            }
+            else
+            {
+                Log.Warning("Error in chatterbot: {Error}", error.Value);
+            }
+
+            Log.Information("""
+                            CleverBot Executed
+                            Server: {GuildName} [{GuildId}]
+                            Channel: {ChannelName} [{ChannelId}]
+                            UserId: {Author} [{AuthorId}]
+                            Message: {Content}
+                            """,
+                guild.Name,
+                guild.Id,
+                usrMsg.Channel?.Name,
+                usrMsg.Channel?.Id,
+                usrMsg.Author,
+                usrMsg.Author.Id,
+                usrMsg.Content);
+
+            return true;
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "Error in cleverbot");
+        }
+
+        return false;
+    }
+
+    public async Task<bool> ToggleChatterBotAsync(ulong guildId)
+    {
+        if (ChatterBotGuilds.TryRemove(guildId, out _))
+        {
+            await using var uow = _db.GetDbContext();
+            await uow.Set<GuildConfig>()
+                     .ToLinqToDBTable()
+                     .Where(x => x.GuildId == guildId)
+                     .UpdateAsync((gc) => new GuildConfig()
+                     {
+                         CleverbotEnabled = false
+                     });
+            await uow.SaveChangesAsync();
+            return false;
+        }
+
+        ChatterBotGuilds.TryAdd(guildId, new(() => CreateSession(), true));
+
+        await using (var uow = _db.GetDbContext())
+        {
+            await uow.Set<GuildConfig>()
+                     .ToLinqToDBTable()
+                     .Where(x => x.GuildId == guildId)
+                     .UpdateAsync((gc) => new GuildConfig()
+                     {
+                         CleverbotEnabled = true
+                     });
+
+            await uow.SaveChangesAsync();
+        }
+
+        return true;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/Choice.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/Choice.cs
new file mode 100644
index 0000000..c1290dd
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/Choice.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public class Choice
+{
+    [JsonPropertyName("message")]
+    public required Message Message { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs
new file mode 100644
index 0000000..2f83164
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs
@@ -0,0 +1,8 @@
+#nullable disable
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public class CleverbotResponse
+{
+    public string Cs { get; set; }
+    public string Output { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs
new file mode 100644
index 0000000..0372c87
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs
@@ -0,0 +1,10 @@
+#nullable disable
+using OneOf;
+using OneOf.Types;
+
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public interface IChatterBotSession
+{
+    Task<OneOf<ThinkResult, Error<string>>> Think(string input, string username);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/Message.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/Message.cs
new file mode 100644
index 0000000..df26315
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/Message.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public class Message
+{
+    [JsonPropertyName("content")]
+    public required string Content { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs
new file mode 100644
index 0000000..d435947
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs
@@ -0,0 +1,45 @@
+#nullable disable
+using Newtonsoft.Json;
+using OneOf;
+using OneOf.Types;
+
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public class OfficialCleverbotSession : IChatterBotSession
+{
+    private string QueryString
+        => $"https://www.cleverbot.com/getreply?key={_apiKey}" + "&wrapper=nadekobot" + "&input={0}" + "&cs={1}";
+
+    private readonly string _apiKey;
+    private readonly IHttpClientFactory _httpFactory;
+    private string cs;
+
+    public OfficialCleverbotSession(string apiKey, IHttpClientFactory factory)
+    {
+        _apiKey = apiKey;
+        _httpFactory = factory;
+    }
+
+    public async Task<OneOf<ThinkResult, Error<string>>> Think(string input, string username)
+    {
+        using var http = _httpFactory.CreateClient();
+        var dataString = await http.GetStringAsync(string.Format(QueryString, input, cs ?? ""));
+        try
+        {
+            var data = JsonConvert.DeserializeObject<CleverbotResponse>(dataString);
+
+            cs = data?.Cs;
+            return new ThinkResult
+            {
+                Text = data?.Output,
+                TokensIn = 2,
+                TokensOut = 1
+            };
+        }
+        catch
+        {
+            Log.Warning("Unexpected response from CleverBot: {ResponseString}", dataString);
+            return new Error<string>("Unexpected CleverBot response received");
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiMessage.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiMessage.cs
new file mode 100644
index 0000000..9f0a028
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiMessage.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public class OpenAiApiMessage
+{
+    [JsonPropertyName("role")]
+    public required string Role { get; init; }
+
+    [JsonPropertyName("content")]
+    public required string Content { get; init; }
+
+    [JsonPropertyName("name")]
+    public required string Name { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiRequest.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiRequest.cs
new file mode 100644
index 0000000..3ab7d68
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiRequest.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public class OpenAiApiRequest
+{
+    [JsonPropertyName("model")]
+    public required string Model { get; init; }
+
+    [JsonPropertyName("messages")]
+    public required List<OpenAiApiMessage> Messages { get; init; }
+
+    [JsonPropertyName("temperature")]
+    public required int Temperature { get; init; }
+
+    [JsonPropertyName("max_tokens")]
+    public required int MaxTokens { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiUsageData.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiUsageData.cs
new file mode 100644
index 0000000..174af3e
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiApiUsageData.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public class OpenAiApiUsageData
+{
+    [JsonPropertyName("prompt_tokens")]
+    public int PromptTokens { get; set; }
+    
+    [JsonPropertyName("completion_tokens")]
+    public int CompletionTokens { get; set; }
+    
+    [JsonPropertyName("total_tokens")]
+    public int TotalTokens { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiCompletionResponse.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiCompletionResponse.cs
new file mode 100644
index 0000000..db0b92f
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApi/OpenAiCompletionResponse.cs
@@ -0,0 +1,13 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public class OpenAiCompletionResponse
+{
+    [JsonPropertyName("choices")]
+    public Choice[] Choices { get; set; }
+    
+    [JsonPropertyName("usage")]
+    public OpenAiApiUsageData Usage { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApiSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApiSession.cs
new file mode 100644
index 0000000..42afd22
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OpenAiApiSession.cs
@@ -0,0 +1,147 @@
+#nullable disable
+using Newtonsoft.Json;
+using OneOf.Types;
+using SharpToken;
+using System.Net.Http.Json;
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public partial class OpenAiApiSession : IChatterBotSession
+{
+    private readonly string _baseUrl;
+    private readonly string _apiKey;
+    private readonly string _model;
+    private readonly int _maxHistory;
+    private readonly int _maxTokens;
+    private readonly int _minTokens;
+    private readonly string _ellieUsername;
+    private readonly GptEncoding _encoding;
+    private List<OpenAiApiMessage> messages = new();
+    private readonly IHttpClientFactory _httpFactory;
+
+
+    public OpenAiApiSession(
+        string url,
+        string apiKey,
+        string model,
+        int chatHistory,
+        int maxTokens,
+        int minTokens,
+        string personality,
+        string ellieUsername,
+        IHttpClientFactory factory)
+    {
+        if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out _))
+        {
+            throw new ArgumentException("Invalid OpenAi api url provided", nameof(url));
+        }
+
+        _baseUrl = url.TrimEnd('/');
+
+        _apiKey = apiKey;
+        _model = model;
+        _httpFactory = factory;
+        _maxHistory = chatHistory;
+        _maxTokens = maxTokens;
+        _minTokens = minTokens;
+        _ellieUsername = UsernameCleaner().Replace(ellieUsername, "");
+        _encoding = GptEncoding.GetEncodingForModel("gpt-4o");
+        if (!string.IsNullOrWhiteSpace(personality))
+        {
+            messages.Add(new()
+            {
+                Role = "system",
+                Content = personality,
+                Name = _ellieUsername
+            });
+        }
+    }
+
+
+    [GeneratedRegex("[^a-zA-Z0-9_-]")]
+    private static partial Regex UsernameCleaner();
+
+    public async Task<OneOf.OneOf<ThinkResult, Error<string>>> Think(string input, string username)
+    {
+        username = UsernameCleaner().Replace(username, "");
+
+        messages.Add(new()
+        {
+            Role = "user",
+            Content = input,
+            Name = username
+        });
+
+        while (messages.Count > _maxHistory + 2)
+        {
+            messages.RemoveAt(1);
+        }
+
+        var tokensUsed = messages.Sum(message => _encoding.Encode(message.Content).Count);
+
+        tokensUsed *= 2;
+
+        //check if we have the minimum number of tokens available to use. Remove messages until we have enough, otherwise exit out and inform the user why.
+        while (_maxTokens - tokensUsed <= _minTokens)
+        {
+            if (messages.Count > 2)
+            {
+                var tokens = _encoding.Encode(messages[1].Content).Count * 2;
+                tokensUsed -= tokens;
+                messages.RemoveAt(1);
+            }
+            else
+            {
+                return new Error<string>(
+                    "Token count exceeded, please increase the number of tokens in the bot config and restart.");
+            }
+        }
+
+        using var http = _httpFactory.CreateClient();
+        http.DefaultRequestHeaders.Authorization = new("Bearer", _apiKey);
+
+        var data = await http.PostAsJsonAsync($"{_baseUrl}/v1/chat/completions",
+            new OpenAiApiRequest()
+            {
+                Model = _model,
+                Messages = messages,
+                MaxTokens = _maxTokens - tokensUsed,
+                Temperature = 1,
+            });
+
+        var dataString = await data.Content.ReadAsStringAsync();
+        try
+        {
+            var response = JsonConvert.DeserializeObject<OpenAiCompletionResponse>(dataString);
+
+            // Log.Information("Received response: {Response} ", dataString);
+            var res = response?.Choices?[0];
+            var message = res?.Message?.Content;
+
+            if (message is null)
+            {
+                return new Error<string>("ChatGpt: Received no response.");
+            }
+
+            messages.Add(new()
+            {
+                Role = "assistant",
+                Content = message,
+                Name = _ellieUsername
+            });
+
+            return new ThinkResult()
+            {
+                Text = message,
+                TokensIn = response.Usage.PromptTokens,
+                TokensOut = response.Usage.CompletionTokens
+            };
+        }
+        catch
+        {
+            Log.Warning("Unexpected response received from OpenAI: {ResponseString}", dataString);
+            return new Error<string>("Unexpected response received");
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/ThinkResult.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/ThinkResult.cs
new file mode 100644
index 0000000..3d881e2
--- /dev/null
+++ b/src/EllieBot/Modules/Games/ChatterBot/_common/ThinkResult.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Modules.Games.Common.ChatterBot;
+
+public sealed class ThinkResult
+{
+    public string Text { get; set; }
+    public int TokensIn { get; set; }
+    public int TokensOut { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Games.cs b/src/EllieBot/Modules/Games/Games.cs
new file mode 100644
index 0000000..c14d6ee
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Games.cs
@@ -0,0 +1,47 @@
+#nullable disable
+using EllieBot.Modules.Games.Services;
+
+namespace EllieBot.Modules.Games;
+
+/* more games
+- Shiritori
+- Simple RPG adventure
+*/
+public partial class Games : EllieModule<GamesService>
+{
+    private readonly IImageCache _images;
+    private readonly IHttpClientFactory _httpFactory;
+    private readonly Random _rng = new();
+
+    public Games(IImageCache images, IHttpClientFactory factory)
+    {
+        _images = images;
+        _httpFactory = factory;
+    }
+
+    [Cmd]
+    public async Task Choose([Leftover] string list = null)
+    {
+        if (string.IsNullOrWhiteSpace(list))
+            return;
+        var listArr = list.Split(';');
+        if (listArr.Length < 2)
+            return;
+        var rng = new EllieRandom();
+        await Response().Confirm("🤔", listArr[rng.Next(0, listArr.Length)]).SendAsync();
+    }
+
+    [Cmd]
+    public async Task EightBall([Leftover] string question = null)
+    {
+        if (string.IsNullOrWhiteSpace(question))
+            return;
+
+        var res = _service.GetEightballResponse(ctx.User.Id, question);
+        await Response().Embed(_sender.CreateEmbed()
+            .WithOkColor()
+            .WithDescription(ctx.User.ToString())
+            .AddField("❓ " + GetText(strs.question), question)
+            .AddField("🎱 " + GetText(strs._8ball), res)).SendAsync();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/GamesConfig.cs b/src/EllieBot/Modules/Games/GamesConfig.cs
new file mode 100644
index 0000000..9308329
--- /dev/null
+++ b/src/EllieBot/Modules/Games/GamesConfig.cs
@@ -0,0 +1,195 @@
+#nullable disable
+using Cloneable;
+using EllieBot.Common.Yml;
+
+namespace EllieBot.Modules.Games.Common;
+
+[Cloneable]
+public sealed partial class GamesConfig : ICloneable<GamesConfig>
+{
+    [Comment("DO NOT CHANGE")]
+    public int Version { get; set; } = 5;
+
+    [Comment("Hangman related settings (.hangman command)")]
+    public HangmanConfig Hangman { get; set; } = new()
+    {
+        CurrencyReward = 0
+    };
+
+    [Comment("Trivia related settings (.t command)")]
+    public TriviaConfig Trivia { get; set; } = new()
+    {
+        CurrencyReward = 0,
+        MinimumWinReq = 1
+    };
+
+    [Comment("List of responses for the .8ball command. A random one will be selected every time")]
+    public List<string> EightBallResponses { get; set; } =
+    [
+        "Most definitely yes.",
+        "For sure.",
+        "Totally!",
+        "Of course!",
+        "As I see it, yes.",
+        "My sources say yes.",
+        "Yes.",
+        "Most likely.",
+        "Perhaps...",
+        "Maybe...",
+        "Hm, not sure.",
+        "It is uncertain.",
+        "Ask me again later.",
+        "Don't count on it.",
+        "Probably not.",
+        "Very doubtful.",
+        "Most likely no.",
+        "Nope.",
+        "No.",
+        "My sources say no.",
+        "Don't even think about it.",
+        "Definitely no.",
+        "NO - It may cause disease contraction!"
+    ];
+
+    [Comment("List of animals which will be used for the animal race game (.race)")]
+    public List<RaceAnimal> RaceAnimals { get; set; } =
+    [
+        new()
+        {
+            Icon = "🐼",
+            Name = "Panda"
+        },
+
+        new()
+        {
+            Icon = "🐻",
+            Name = "Bear"
+        },
+
+        new()
+        {
+            Icon = "🐧",
+            Name = "Pengu"
+        },
+
+        new()
+        {
+            Icon = "🐨",
+            Name = "Koala"
+        },
+
+        new()
+        {
+            Icon = "🐬",
+            Name = "Dolphin"
+        },
+
+        new()
+        {
+            Icon = "🐞",
+            Name = "Ladybird"
+        },
+
+        new()
+        {
+            Icon = "🦀",
+            Name = "Crab"
+        },
+
+        new()
+        {
+            Icon = "🦄",
+            Name = "Unicorn"
+        }
+    ];
+
+    [Comment(
+        """
+         Which chatbot API should bot use.
+        'cleverbot' - bot will use Cleverbot API. 
+        'openai' - bot will use OpenAi API
+        """)]
+    public ChatBotImplementation ChatBot { get; set; } = ChatBotImplementation.OpenAi;
+
+    public ChatGptConfig ChatGpt { get; set; } = new();
+}
+
+[Cloneable]
+public sealed partial class ChatGptConfig
+{
+    [Comment("""
+             Url to any openai api compatible url.
+             Make sure to modify the modelName appropriately
+             DO NOT add /v1/chat/completions suffix to the url
+             """)]
+    public string ApiUrl { get; set; } = "https://api.openai.com";
+
+    [Comment("""
+             Which GPT Model should bot use.
+             gpt-3.5-turbo - cheapest
+             gpt-4o - more expensive, higher quality
+
+             If you are using another openai compatible api, you may use any of the models supported by that api
+             """)]
+    public string ModelName { get; set; } = "gpt-3.5-turbo";
+
+    [Comment("""
+             How should the chatbot behave, what's its personality?
+             This will be sent as a system message.
+             Usage of this counts towards the max tokens.
+             """)]
+    public string PersonalityPrompt { get; set; } =
+        "You are a chat bot willing to have a conversation with anyone about anything.";
+
+    [Comment(
+        """
+        The maximum number of messages in a conversation that can be remembered. 
+        This will increase the number of tokens used.
+        """)]
+    public int ChatHistory { get; set; } = 5;
+
+    [Comment(@"The maximum number of tokens to use per OpenAi API call")]
+    public int MaxTokens { get; set; } = 100;
+
+    [Comment(@"The minimum number of tokens to use per GPT API call, such that chat history is removed to make room.")]
+    public int MinTokens { get; set; } = 30;
+}
+
+[Cloneable]
+public sealed partial class HangmanConfig
+{
+    [Comment("The amount of currency awarded to the winner of a hangman game")]
+    public long CurrencyReward { get; set; }
+}
+
+[Cloneable]
+public sealed partial class TriviaConfig
+{
+    [Comment("The amount of currency awarded to the winner of the trivia game.")]
+    public long CurrencyReward { get; set; }
+
+    [Comment("""
+             Users won't be able to start trivia games which have 
+             a smaller win requirement than the one specified by this setting.
+             """)]
+    public int MinimumWinReq { get; set; } = 1;
+}
+
+[Cloneable]
+public sealed partial class RaceAnimal
+{
+    public string Icon { get; set; }
+    public string Name { get; set; }
+}
+
+public enum ChatBotImplementation
+{
+    Cleverbot,
+    OpenAi = 1,
+
+    [Obsolete]
+    Gpt = 1,
+
+    [Obsolete]
+    Gpt3 = 1,
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/GamesConfigService.cs b/src/EllieBot/Modules/Games/GamesConfigService.cs
new file mode 100644
index 0000000..f6ff635
--- /dev/null
+++ b/src/EllieBot/Modules/Games/GamesConfigService.cs
@@ -0,0 +1,120 @@
+#nullable disable
+using EllieBot.Common.Configs;
+using EllieBot.Modules.Games.Common;
+
+namespace EllieBot.Modules.Games.Services;
+
+public sealed class GamesConfigService : ConfigServiceBase<GamesConfig>
+{
+    private const string FILE_PATH = "data/games.yml";
+    private static readonly TypedKey<GamesConfig> _changeKey = new("config.games.updated");
+    public override string Name { get; } = "games";
+
+    public GamesConfigService(IConfigSeria serializer, IPubSub pubSub)
+        : base(FILE_PATH, serializer, pubSub, _changeKey)
+    {
+        AddParsedProp("trivia.min_win_req",
+            gs => gs.Trivia.MinimumWinReq,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            val => val > 0);
+        AddParsedProp("trivia.currency_reward",
+            gs => gs.Trivia.CurrencyReward,
+            long.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+        AddParsedProp("hangman.currency_reward",
+            gs => gs.Hangman.CurrencyReward,
+            long.TryParse,
+            ConfigPrinters.ToString,
+            val => val >= 0);
+        AddParsedProp("chatbot",
+            gs => gs.ChatBot,
+            ConfigParsers.InsensitiveEnum,
+            ConfigPrinters.ToString);
+
+        AddParsedProp("gpt.apiUrl",
+            gs => gs.ChatGpt.ApiUrl,
+            ConfigParsers.String,
+            ConfigPrinters.ToString);
+
+        AddParsedProp("gpt.modelName",
+            gs => gs.ChatGpt.ModelName,
+            ConfigParsers.String,
+            ConfigPrinters.ToString);
+
+        AddParsedProp("gpt.personality",
+            gs => gs.ChatGpt.PersonalityPrompt,
+            ConfigParsers.String,
+            ConfigPrinters.ToString);
+
+        Migrate();
+    }
+
+    private void Migrate()
+    {
+        if (data.Version < 1)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 1;
+                c.Hangman = new()
+                {
+                    CurrencyReward = 0
+                };
+            });
+        }
+
+        if (data.Version < 3)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 3;
+                c.ChatGpt.ModelName = "gpt35turbo";
+            });
+        }
+
+        if (data.Version < 4)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 4;
+#pragma warning disable CS0612 // Type or member is obsolete
+                c.ChatGpt.ModelName =
+                    c.ChatGpt.ModelName.Equals("gpt4", StringComparison.OrdinalIgnoreCase)
+                    || c.ChatGpt.ModelName.Equals("gpt432k", StringComparison.OrdinalIgnoreCase)
+                        ? "gpt-4o"
+                        : "gpt-3.5-turbo";
+#pragma warning restore CS0612 // Type or member is obsolete
+            });
+        }
+
+        if (data.Version < 5)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 5;
+                c.ChatBot = c.ChatBot == ChatBotImplementation.OpenAi
+                    ? ChatBotImplementation.OpenAi
+                    : c.ChatBot;
+
+                if (c.ChatGpt.ModelName.Equals("gpt4o", StringComparison.OrdinalIgnoreCase))
+                {
+                    c.ChatGpt.ModelName = "gpt-4o";
+                }
+                else if (c.ChatGpt.ModelName.Equals("gpt35turbo", StringComparison.OrdinalIgnoreCase))
+                {
+                    c.ChatGpt.ModelName = "gpt-3.5-turbo";
+                }
+                else
+                {
+                    Log.Warning(
+                        "Unknown OpenAI api model name: {ModelName}. "
+                        + "It will be reset to 'gpt-3.5-turbo' only this time",
+                        c.ChatGpt.ModelName);
+                    c.ChatGpt.ModelName = "gpt-3.5-turbo";
+                }
+            });
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/GamesService.cs b/src/EllieBot/Modules/Games/GamesService.cs
new file mode 100644
index 0000000..1436285
--- /dev/null
+++ b/src/EllieBot/Modules/Games/GamesService.cs
@@ -0,0 +1,87 @@
+#nullable disable
+using Microsoft.Extensions.Caching.Memory;
+using EllieBot.Modules.Games.Common;
+using EllieBot.Modules.Games.Common.Acrophobia;
+using EllieBot.Modules.Games.Common.Nunchi;
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Games.Services;
+
+public class GamesService : IEService
+{
+    private const string TYPING_ARTICLES_PATH = "data/typing_articles3.json";
+
+
+    public IReadOnlyList<string> EightBallResponses
+        => _gamesConfig.Data.EightBallResponses;
+
+    public List<TypingArticle> TypingArticles { get; } = new();
+
+    //channelId, game
+    public ConcurrentDictionary<ulong, AcrophobiaGame> AcrophobiaGames { get; } = new();
+    public Dictionary<ulong, TicTacToe> TicTacToeGames { get; } = new();
+    public ConcurrentDictionary<ulong, TypingGame> RunningContests { get; } = new();
+    public ConcurrentDictionary<ulong, NunchiGame> NunchiGames { get; } = new();
+
+    private readonly GamesConfigService _gamesConfig;
+
+    private readonly IHttpClientFactory _httpFactory;
+    private readonly IMemoryCache _8BallCache;
+    private readonly Random _rng;
+
+    public GamesService(GamesConfigService gamesConfig, IHttpClientFactory httpFactory)
+    {
+        _gamesConfig = gamesConfig;
+        _httpFactory = httpFactory;
+        _8BallCache = new MemoryCache(new MemoryCacheOptions
+        {
+            SizeLimit = 500_000
+        });
+
+        _rng = new EllieRandom();
+
+        try
+        {
+            TypingArticles = JsonConvert.DeserializeObject<List<TypingArticle>>(File.ReadAllText(TYPING_ARTICLES_PATH));
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "Error while loading typing articles: {ErrorMessage}", ex.Message);
+            TypingArticles = new();
+        }
+    }
+
+    public void AddTypingArticle(IUser user, string text)
+    {
+        TypingArticles.Add(new()
+        {
+            Source = user.ToString(),
+            Extra = $"Text added on {DateTime.UtcNow} by {user}.",
+            Text = text.SanitizeMentions(true)
+        });
+
+        File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(TypingArticles));
+    }
+
+    public string GetEightballResponse(ulong userId, string question)
+        => _8BallCache.GetOrCreate($"8ball:{userId}:{question}",
+            e =>
+            {
+                e.Size = question.Length;
+                e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12);
+                return EightBallResponses[_rng.Next(0, EightBallResponses.Count)];
+            });
+
+    public TypingArticle RemoveTypingArticle(int index)
+    {
+        var articles = TypingArticles;
+        if (index < 0 || index >= articles.Count)
+            return null;
+
+        var removed = articles[index];
+        TypingArticles.RemoveAt(index);
+
+        File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(articles));
+        return removed;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs b/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs
new file mode 100644
index 0000000..333e8f0
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs
@@ -0,0 +1,64 @@
+using EllieBot.Common.Yml;
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieBot.Modules.Games.Hangman;
+
+public sealed class DefaultHangmanSource : IHangmanSource
+{
+    private IReadOnlyDictionary<string, HangmanTerm[]> termsDict = new Dictionary<string, HangmanTerm[]>();
+    private readonly Random _rng;
+
+    public DefaultHangmanSource()
+    {
+        _rng = new EllieRandom();
+        Reload();
+    }
+
+    public void Reload()
+    {
+        if (!Directory.Exists("data/hangman"))
+        {
+            Log.Error("Hangman game won't work. Folder 'data/hangman' is missing");
+            return;
+        }
+
+        var qs = new Dictionary<string, HangmanTerm[]>();
+        foreach (var file in Directory.EnumerateFiles("data/hangman/", "*.yml"))
+        {
+            try
+            {
+                var data = Yaml.Deserializer.Deserialize<HangmanTerm[]>(File.ReadAllText(file));
+                qs[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = data;
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "Loading {HangmanFile} failed", file);
+            }
+        }
+
+        termsDict = qs;
+
+        Log.Information("Loaded {HangmanCategoryCount} hangman categories", qs.Count);
+    }
+
+    public IReadOnlyCollection<string> GetCategories()
+        => termsDict.Keys.ToList();
+
+    public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term)
+    {
+        if (category is null)
+        {
+            var cats = GetCategories();
+            category = cats.ElementAt(_rng.Next(0, cats.Count));
+        }
+
+        if (termsDict.TryGetValue(category, out var terms))
+        {
+            term = terms[_rng.Next(0, terms.Length)];
+            return true;
+        }
+
+        term = null;
+        return false;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs b/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs
new file mode 100644
index 0000000..acc0323
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs
@@ -0,0 +1,76 @@
+using EllieBot.Modules.Games.Hangman;
+
+namespace EllieBot.Modules.Games;
+
+public partial class Games
+{
+    [Group]
+    public partial class HangmanCommands : EllieModule<IHangmanService>
+    {
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Hangmanlist()
+            => await Response().Confirm(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n')).SendAsync();
+
+        private static string Draw(HangmanGame.State state)
+            => $"""
+            . ┌─────┐
+            .┃...............┋
+            .┃...............┋
+            .┃{(state.Errors > 0 ? ".............😲" : "")}
+            .┃{(state.Errors > 1 ? "............./" : "")} {(state.Errors > 2 ? "|" : "")} {(state.Errors > 3 ? "\\" : "")}
+            .┃{(state.Errors > 4 ? "............../" : "")} {(state.Errors > 5 ? "\\" : "")}
+            /-\
+            """;
+
+        public static EmbedBuilder GetEmbed(IMessageSenderService sender, HangmanGame.State state)
+        {
+            if (state.Phase == HangmanGame.Phase.Running)
+            {
+                return sender.CreateEmbed()
+                         .WithOkColor()
+                         .AddField("Hangman", Draw(state))
+                         .AddField("Guess", Format.Code(state.Word))
+                         .WithFooter(state.MissedLetters.Join(' '));
+            }
+
+            if (state.Phase == HangmanGame.Phase.Ended && state.Failed)
+            {
+                return sender.CreateEmbed()
+                         .WithErrorColor()
+                         .AddField("Hangman", Draw(state))
+                         .AddField("Guess", Format.Code(state.Word))
+                         .WithFooter(state.MissedLetters.Join(' '));
+            }
+
+            return sender.CreateEmbed()
+                     .WithOkColor()
+                     .AddField("Hangman", Draw(state))
+                     .AddField("Guess", Format.Code(state.Word))
+                     .WithFooter(state.MissedLetters.Join(' '));
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Hangman([Leftover] string? type = null)
+        {
+            if (!_service.StartHangman(ctx.Channel.Id, type, out var hangman))
+            {
+                await Response().Error(strs.hangman_running).SendAsync();
+                return;
+            }
+
+            var eb = GetEmbed(_sender, hangman);
+            eb.WithDescription(GetText(strs.hangman_game_started));
+            await Response().Embed(eb).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task HangmanStop()
+        {
+            if (await _service.StopHangman(ctx.Channel.Id))
+                await Response().Confirm(strs.hangman_stopped).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs b/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs
new file mode 100644
index 0000000..58e779b
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs
@@ -0,0 +1,112 @@
+#nullable disable
+
+namespace EllieBot.Modules.Games.Hangman;
+
+public sealed class HangmanGame
+{
+    public enum GuessResult { NoAction, AlreadyTried, Incorrect, Guess, Win }
+
+    public enum Phase { Running, Ended }
+
+    private Phase CurrentPhase { get; set; }
+
+    private readonly HashSet<char> _incorrect = new();
+    private readonly HashSet<char> _correct = new();
+    private readonly HashSet<char> _remaining = new();
+
+    private readonly string _word;
+    private readonly string _imageUrl;
+
+    public HangmanGame(HangmanTerm term)
+    {
+        _word = term.Word;
+        _imageUrl = term.ImageUrl;
+
+        _remaining = _word.ToLowerInvariant().Where(x => char.IsLetter(x)).Select(char.ToLowerInvariant).ToHashSet();
+    }
+
+    public State GetState(GuessResult guessResult = GuessResult.NoAction)
+        => new(_incorrect.Count,
+            CurrentPhase,
+            CurrentPhase == Phase.Ended ? _word : GetScrambledWord(),
+            guessResult,
+            _incorrect.ToList(),
+            CurrentPhase == Phase.Ended ? _imageUrl : string.Empty);
+
+    private string GetScrambledWord()
+    {
+        Span<char> output = stackalloc char[_word.Length * 2];
+        for (var i = 0; i < _word.Length; i++)
+        {
+            var ch = _word[i];
+            if (ch == ' ')
+                output[i * 2] = ' ';
+            if (!char.IsLetter(ch) || !_remaining.Contains(char.ToLowerInvariant(ch)))
+                output[i * 2] = ch;
+            else
+                output[i * 2] = '_';
+
+            output[(i * 2) + 1] = ' ';
+        }
+
+        return new(output);
+    }
+
+    public State Guess(string guess)
+    {
+        if (CurrentPhase != Phase.Running)
+            return GetState();
+
+        guess = guess.Trim();
+        if (guess.Length > 1)
+        {
+            if (guess.Equals(_word, StringComparison.InvariantCultureIgnoreCase))
+            {
+                CurrentPhase = Phase.Ended;
+                return GetState(GuessResult.Win);
+            }
+
+            return GetState();
+        }
+
+        var charGuess = guess[0];
+        if (!char.IsLetter(charGuess))
+            return GetState();
+
+        if (_incorrect.Contains(charGuess) || _correct.Contains(charGuess))
+            return GetState(GuessResult.AlreadyTried);
+
+        if (_remaining.Remove(charGuess))
+        {
+            if (_remaining.Count == 0)
+            {
+                CurrentPhase = Phase.Ended;
+                return GetState(GuessResult.Win);
+            }
+
+            _correct.Add(charGuess);
+            return GetState(GuessResult.Guess);
+        }
+
+        _incorrect.Add(charGuess);
+        if (_incorrect.Count > 5)
+        {
+            CurrentPhase = Phase.Ended;
+            return GetState(GuessResult.Incorrect);
+        }
+
+        return GetState(GuessResult.Incorrect);
+    }
+
+    public record State(
+        int Errors,
+        Phase Phase,
+        string Word,
+        GuessResult GuessResult,
+        List<char> MissedLetters,
+        string ImageUrl)
+    {
+        public bool Failed
+            => Errors > 5;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanService.cs b/src/EllieBot/Modules/Games/Hangman/HangmanService.cs
new file mode 100644
index 0000000..0b5c9c0
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Hangman/HangmanService.cs
@@ -0,0 +1,136 @@
+using Microsoft.Extensions.Caching.Memory;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Modules.Games.Services;
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieBot.Modules.Games.Hangman;
+
+public sealed class HangmanService : IHangmanService, IExecNoCommand
+{
+    private readonly ConcurrentDictionary<ulong, HangmanGame> _hangmanGames = new();
+    private readonly IHangmanSource _source;
+    private readonly IMessageSenderService _sender;
+    private readonly GamesConfigService _gcs;
+    private readonly ICurrencyService _cs;
+    private readonly IMemoryCache _cdCache;
+    private readonly object _locker = new();
+
+    public HangmanService(
+        IHangmanSource source,
+        IMessageSenderService sender,
+        GamesConfigService gcs,
+        ICurrencyService cs,
+        IMemoryCache cdCache)
+    {
+        _source = source;
+        _sender = sender;
+        _gcs = gcs;
+        _cs = cs;
+        _cdCache = cdCache;
+    }
+
+    public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state)
+    {
+        state = null;
+        if (!_source.GetTerm(category, out var term))
+            return false;
+
+
+        var game = new HangmanGame(term);
+        lock (_locker)
+        {
+            var hc = _hangmanGames.GetOrAdd(channelId, game);
+            if (hc == game)
+            {
+                state = hc.GetState();
+                return true;
+            }
+
+            return false;
+        }
+    }
+
+    public ValueTask<bool> StopHangman(ulong channelId)
+    {
+        lock (_locker)
+        {
+            if (_hangmanGames.TryRemove(channelId, out _))
+                return new(true);
+        }
+
+        return new(false);
+    }
+
+    public IReadOnlyCollection<string> GetHangmanTypes()
+        => _source.GetCategories();
+
+    public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
+    {
+        if (_hangmanGames.ContainsKey(msg.Channel.Id))
+        {
+            if (string.IsNullOrWhiteSpace(msg.Content))
+                return;
+
+            if (_cdCache.TryGetValue(msg.Author.Id, out _))
+                return;
+
+            HangmanGame.State state;
+            long rew = 0;
+            lock (_locker)
+            {
+                if (!_hangmanGames.TryGetValue(msg.Channel.Id, out var game))
+                    return;
+
+                state = game.Guess(msg.Content.ToLowerInvariant());
+
+                if (state.GuessResult == HangmanGame.GuessResult.NoAction)
+                    return;
+
+                if (state.GuessResult is HangmanGame.GuessResult.Incorrect or HangmanGame.GuessResult.AlreadyTried)
+                {
+                    _cdCache.Set(msg.Author.Id,
+                        string.Empty,
+                        new MemoryCacheEntryOptions
+                        {
+                            AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3)
+                        });
+                }
+
+                if (state.Phase == HangmanGame.Phase.Ended)
+                {
+                    if (_hangmanGames.TryRemove(msg.Channel.Id, out _))
+                        rew = _gcs.Data.Hangman.CurrencyReward;
+                }
+            }
+
+            if (rew > 0)
+                await _cs.AddAsync(msg.Author, rew, new("hangman", "win"));
+
+            await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state);
+        }
+    }
+
+    private Task<IUserMessage> SendState(
+        ITextChannel channel,
+        IUser user,
+        string content,
+        HangmanGame.State state)
+    {
+        var embed = Games.HangmanCommands.GetEmbed(_sender, state);
+        if (state.GuessResult == HangmanGame.GuessResult.Guess)
+            embed.WithDescription($"{user} guessed the letter {content}!").WithOkColor();
+        else if (state.GuessResult == HangmanGame.GuessResult.Incorrect && state.Failed)
+            embed.WithDescription($"{user} Letter {content} doesn't exist! Game over!").WithErrorColor();
+        else if (state.GuessResult == HangmanGame.GuessResult.Incorrect)
+            embed.WithDescription($"{user} Letter {content} doesn't exist!").WithErrorColor();
+        else if (state.GuessResult == HangmanGame.GuessResult.AlreadyTried)
+            embed.WithDescription($"{user} Letter {content} has already been used.").WithPendingColor();
+        else if (state.GuessResult == HangmanGame.GuessResult.Win)
+            embed.WithDescription($"{user} won!").WithOkColor();
+
+        if (!string.IsNullOrWhiteSpace(state.ImageUrl) && Uri.IsWellFormedUriString(state.ImageUrl, UriKind.Absolute))
+            embed.WithImageUrl(state.ImageUrl);
+
+        return _sender.Response(channel).Embed(embed).SendAsync();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs b/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs
new file mode 100644
index 0000000..22e5144
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs
@@ -0,0 +1,8 @@
+#nullable disable
+namespace EllieBot.Modules.Games.Hangman;
+
+public sealed class HangmanTerm
+{
+    public string Word { get; set; }
+    public string ImageUrl { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs b/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs
new file mode 100644
index 0000000..da8d027
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs
@@ -0,0 +1,10 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieBot.Modules.Games.Hangman;
+
+public interface IHangmanService
+{
+    bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? hangmanController);
+    ValueTask<bool> StopHangman(ulong channelId);
+    IReadOnlyCollection<string> GetHangmanTypes();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs b/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs
new file mode 100644
index 0000000..d28199b
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs
@@ -0,0 +1,10 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieBot.Modules.Games.Hangman;
+
+public interface IHangmanSource : IEService
+{
+    public IReadOnlyCollection<string> GetCategories();
+    public void Reload();
+    public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs b/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs
new file mode 100644
index 0000000..6fa579c
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs
@@ -0,0 +1,183 @@
+#nullable disable
+using System.Collections.Immutable;
+
+namespace EllieBot.Modules.Games.Common.Nunchi;
+
+public sealed class NunchiGame : IDisposable
+{
+    public enum Phase
+    {
+        Joining,
+        Playing,
+        WaitingForNextRound,
+        Ended
+    }
+
+    private const int KILL_TIMEOUT = 20 * 1000;
+    private const int NEXT_ROUND_TIMEOUT = 5 * 1000;
+
+    public event Func<NunchiGame, Task> OnGameStarted;
+    public event Func<NunchiGame, int, Task> OnRoundStarted;
+    public event Func<NunchiGame, Task> OnUserGuessed;
+    public event Func<NunchiGame, (ulong Id, string Name)?, Task> OnRoundEnded; // tuple of the user who failed
+    public event Func<NunchiGame, string, Task> OnGameEnded; // name of the user who won
+
+    public int CurrentNumber { get; private set; } = new EllieRandom().Next(0, 100);
+    public Phase CurrentPhase { get; private set; } = Phase.Joining;
+
+    public ImmutableArray<(ulong Id, string Name)> Participants
+        => participants.ToImmutableArray();
+
+    public int ParticipantCount
+        => participants.Count;
+
+    private readonly SemaphoreSlim _locker = new(1, 1);
+
+    private HashSet<(ulong Id, string Name)> participants = [];
+    private readonly HashSet<(ulong Id, string Name)> _passed = [];
+    private Timer killTimer;
+
+    public NunchiGame(ulong creatorId, string creatorName)
+        => participants.Add((creatorId, creatorName));
+
+    public async Task<bool> Join(ulong userId, string userName)
+    {
+        await _locker.WaitAsync();
+        try
+        {
+            if (CurrentPhase != Phase.Joining)
+                return false;
+
+            return participants.Add((userId, userName));
+        }
+        finally { _locker.Release(); }
+    }
+
+    public async Task<bool> Initialize()
+    {
+        CurrentPhase = Phase.Joining;
+        await Task.Delay(30000);
+        await _locker.WaitAsync();
+        try
+        {
+            if (participants.Count < 3)
+            {
+                CurrentPhase = Phase.Ended;
+                return false;
+            }
+
+            killTimer = new(async _ =>
+                {
+                    await _locker.WaitAsync();
+                    try
+                    {
+                        if (CurrentPhase != Phase.Playing)
+                            return;
+
+                        //if some players took too long to type a number, boot them all out and start a new round
+                        participants = new HashSet<(ulong, string)>(_passed);
+                        EndRound();
+                    }
+                    finally { _locker.Release(); }
+                },
+                null,
+                KILL_TIMEOUT,
+                KILL_TIMEOUT);
+
+            CurrentPhase = Phase.Playing;
+            _ = OnGameStarted?.Invoke(this);
+            _ = OnRoundStarted?.Invoke(this, CurrentNumber);
+            return true;
+        }
+        finally { _locker.Release(); }
+    }
+
+    public async Task Input(ulong userId, string userName, int input)
+    {
+        await _locker.WaitAsync();
+        try
+        {
+            if (CurrentPhase != Phase.Playing)
+                return;
+
+            var userTuple = (Id: userId, Name: userName);
+
+            // if the user is not a member of the race,
+            // or he already successfully typed the number
+            // ignore the input
+            if (!participants.Contains(userTuple) || !_passed.Add(userTuple))
+                return;
+
+            //if the number is correct
+            if (CurrentNumber == input - 1)
+            {
+                //increment current number
+                ++CurrentNumber;
+                if (_passed.Count == participants.Count - 1)
+                {
+                    // if only n players are left, and n - 1 type the correct number, round is over
+
+                    // if only 2 players are left, game is over
+                    if (participants.Count == 2)
+                    {
+                        killTimer.Change(Timeout.Infinite, Timeout.Infinite);
+                        CurrentPhase = Phase.Ended;
+                        _ = OnGameEnded?.Invoke(this, userTuple.Name);
+                    }
+                    else // else just start the new round without the user who was the last
+                    {
+                        var failure = participants.Except(_passed).First();
+
+                        OnUserGuessed?.Invoke(this);
+                        EndRound(failure);
+                        return;
+                    }
+                }
+
+                OnUserGuessed?.Invoke(this);
+            }
+            else
+            {
+                //if the user failed
+
+                EndRound(userTuple);
+            }
+        }
+        finally { _locker.Release(); }
+    }
+
+    private void EndRound((ulong, string)? failure = null)
+    {
+        killTimer.Change(KILL_TIMEOUT, KILL_TIMEOUT);
+        CurrentNumber = new EllieRandom().Next(0, 100); // reset the counter
+        _passed.Clear(); // reset all users who passed (new round starts)
+        if (failure is not null)
+            participants.Remove(failure.Value); // remove the dude who failed from the list of players
+
+        _ = OnRoundEnded?.Invoke(this, failure);
+        if (participants.Count <= 1) // means we have a winner or everyone was booted out
+        {
+            killTimer.Change(Timeout.Infinite, Timeout.Infinite);
+            CurrentPhase = Phase.Ended;
+            _ = OnGameEnded?.Invoke(this, participants.Count > 0 ? participants.First().Name : null);
+            return;
+        }
+
+        CurrentPhase = Phase.WaitingForNextRound;
+        Task.Run(async () =>
+        {
+            await Task.Delay(NEXT_ROUND_TIMEOUT);
+            CurrentPhase = Phase.Playing;
+            _ = OnRoundStarted?.Invoke(this, CurrentNumber);
+        });
+    }
+
+    public void Dispose()
+    {
+        OnGameEnded = null;
+        OnGameStarted = null;
+        OnRoundEnded = null;
+        OnRoundStarted = null;
+        OnUserGuessed = null;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs b/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs
new file mode 100644
index 0000000..80e6c42
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs
@@ -0,0 +1,114 @@
+#nullable disable
+using EllieBot.Modules.Games.Common.Nunchi;
+using EllieBot.Modules.Games.Services;
+
+namespace EllieBot.Modules.Games;
+
+public partial class Games
+{
+    [Group]
+    public partial class NunchiCommands : EllieModule<GamesService>
+    {
+        private readonly DiscordSocketClient _client;
+
+        public NunchiCommands(DiscordSocketClient client)
+            => _client = client;
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Nunchi()
+        {
+            var newNunchi = new NunchiGame(ctx.User.Id, ctx.User.ToString());
+            NunchiGame nunchi;
+
+            //if a game was already active
+            if ((nunchi = _service.NunchiGames.GetOrAdd(ctx.Guild.Id, newNunchi)) != newNunchi)
+            {
+                // join it
+                // if you failed joining, that means game is running or just ended
+                if (!await nunchi.Join(ctx.User.Id, ctx.User.ToString()))
+                    return;
+
+                await Response().Error(strs.nunchi_joined(nunchi.ParticipantCount)).SendAsync();
+                return;
+            }
+
+
+            try { await Response().Confirm(strs.nunchi_created).SendAsync(); }
+            catch { }
+
+            nunchi.OnGameEnded += NunchiOnGameEnded;
+            //nunchi.OnGameStarted += Nunchi_OnGameStarted;
+            nunchi.OnRoundEnded += Nunchi_OnRoundEnded;
+            nunchi.OnUserGuessed += Nunchi_OnUserGuessed;
+            nunchi.OnRoundStarted += Nunchi_OnRoundStarted;
+            _client.MessageReceived += ClientMessageReceived;
+
+            var success = await nunchi.Initialize();
+            if (!success)
+            {
+                if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game))
+                    game.Dispose();
+                await Response().Confirm(strs.nunchi_failed_to_start).SendAsync();
+            }
+
+            Task ClientMessageReceived(SocketMessage arg)
+            {
+                _ = Task.Run(async () =>
+                {
+                    if (arg.Channel.Id != ctx.Channel.Id)
+                        return;
+
+                    if (!int.TryParse(arg.Content, out var number))
+                        return;
+                    try
+                    {
+                        await nunchi.Input(arg.Author.Id, arg.Author.ToString(), number);
+                    }
+                    catch
+                    {
+                    }
+                });
+                return Task.CompletedTask;
+            }
+
+            Task NunchiOnGameEnded(NunchiGame arg1, string arg2)
+            {
+                if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game))
+                {
+                    _client.MessageReceived -= ClientMessageReceived;
+                    game.Dispose();
+                }
+
+                if (arg2 is null)
+                    return Response().Confirm(strs.nunchi_ended_no_winner).SendAsync();
+                return Response().Confirm(strs.nunchi_ended(Format.Bold(arg2))).SendAsync();
+            }
+        }
+
+        private Task Nunchi_OnRoundStarted(NunchiGame arg, int cur)
+            => Response()
+               .Confirm(strs.nunchi_round_started(Format.Bold(arg.ParticipantCount.ToString()),
+                   Format.Bold(cur.ToString())))
+               .SendAsync();
+
+        private Task Nunchi_OnUserGuessed(NunchiGame arg)
+            => Response().Confirm(strs.nunchi_next_number(Format.Bold(arg.CurrentNumber.ToString()))).SendAsync();
+
+        private Task Nunchi_OnRoundEnded(NunchiGame arg1, (ulong Id, string Name)? arg2)
+        {
+            if (arg2.HasValue)
+                return Response().Confirm(strs.nunchi_round_ended(Format.Bold(arg2.Value.Name))).SendAsync();
+            return Response()
+                   .Confirm(strs.nunchi_round_ended_boot(
+                       Format.Bold("\n"
+                                   + string.Join("\n, ",
+                                       arg1.Participants.Select(x
+                                           => x.Name)))))
+                   .SendAsync(); // this won't work if there are too many users
+        }
+
+        private Task Nunchi_OnGameStarted(NunchiGame arg)
+            => Response().Confirm(strs.nunchi_started(Format.Bold(arg.ParticipantCount.ToString()))).SendAsync();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs b/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs
new file mode 100644
index 0000000..65b8bbd
--- /dev/null
+++ b/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs
@@ -0,0 +1,105 @@
+#nullable disable
+using EllieBot.Modules.Games.Common;
+using EllieBot.Modules.Games.Services;
+
+namespace EllieBot.Modules.Games;
+
+public partial class Games
+{
+    [Group]
+    public partial class SpeedTypingCommands : EllieModule<GamesService>
+    {
+        private readonly GamesService _games;
+        private readonly DiscordSocketClient _client;
+
+        public SpeedTypingCommands(DiscordSocketClient client, GamesService games)
+        {
+            _games = games;
+            _client = client;
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [EllieOptions<TypingGame.Options>]
+        public async Task TypeStart(params string[] args)
+        {
+            var (options, _) = OptionsParser.ParseFrom(new TypingGame.Options(), args);
+            var channel = (ITextChannel)ctx.Channel;
+
+            var game = _service.RunningContests.GetOrAdd(ctx.Guild.Id,
+                _ => new(_games, _client, channel, prefix, options, _sender));
+
+            if (game.IsActive)
+                await Response().Error($"Contest already running in {game.Channel.Mention} channel.").SendAsync();
+            else
+                await game.Start();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task TypeStop()
+        {
+            if (_service.RunningContests.TryRemove(ctx.Guild.Id, out var game))
+            {
+                await game.Stop();
+                return;
+            }
+
+            await Response().Error("No contest to stop on this channel.").SendAsync();
+        }
+
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [OwnerOnly]
+        public async Task Typeadd([Leftover] string text)
+        {
+            if (string.IsNullOrWhiteSpace(text))
+                return;
+
+            _games.AddTypingArticle(ctx.User, text);
+
+            await Response().Confirm("Added new article for typing game.").SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Typelist(int page = 1)
+        {
+            if (page < 1)
+                return;
+
+            var articles = _games.TypingArticles.Skip((page - 1) * 15).Take(15).ToArray();
+
+            if (!articles.Any())
+            {
+                await Response().Error($"{ctx.User.Mention} `No articles found on that page.`").SendAsync();
+                return;
+            }
+
+            var i = (page - 1) * 15;
+            await Response()
+                  .Confirm("List of articles for Type Race",
+                      string.Join("\n", articles.Select(a => $"`#{++i}` - {a.Text.TrimTo(50)}")))
+                  .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [OwnerOnly]
+        public async Task Typedel(int index)
+        {
+            var removed = _service.RemoveTypingArticle(--index);
+
+            if (removed is null)
+                return;
+
+            var embed = _sender.CreateEmbed()
+                           .WithTitle($"Removed typing article #{index + 1}")
+                           .WithDescription(removed.Text.TrimTo(50))
+                           .WithOkColor();
+
+            await Response().Embed(embed).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs b/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs
new file mode 100644
index 0000000..cb55893
--- /dev/null
+++ b/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Modules.Games.Common;
+
+public class TypingArticle
+{
+    public string Source { get; set; }
+    public string Extra { get; set; }
+    public string Text { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs b/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs
new file mode 100644
index 0000000..d712525
--- /dev/null
+++ b/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs
@@ -0,0 +1,197 @@
+#nullable disable
+using CommandLine;
+using EllieBot.Modules.Games.Services;
+using System.Diagnostics;
+
+namespace EllieBot.Modules.Games.Common;
+
+public class TypingGame
+{
+    public const float WORD_VALUE = 4.5f;
+    public ITextChannel Channel { get; }
+    public string CurrentSentence { get; private set; }
+    public bool IsActive { get; private set; }
+    private readonly Stopwatch _sw;
+    private readonly List<ulong> _finishedUserIds;
+    private readonly DiscordSocketClient _client;
+    private readonly GamesService _games;
+    private readonly string _prefix;
+    private readonly Options _options;
+    private readonly IMessageSenderService _sender;
+
+    public TypingGame(
+        GamesService games,
+        DiscordSocketClient client,
+        ITextChannel channel,
+        string prefix,
+        Options options,
+        IMessageSenderService sender)
+    {
+        _games = games;
+        _client = client;
+        _prefix = prefix;
+        _options = options;
+        _sender = sender;
+
+        Channel = channel;
+        IsActive = false;
+        _sw = new();
+        _finishedUserIds = new();
+    }
+
+    public async Task<bool> Stop()
+    {
+        if (!IsActive)
+            return false;
+        _client.MessageReceived -= AnswerReceived;
+        _finishedUserIds.Clear();
+        IsActive = false;
+        _sw.Stop();
+        _sw.Reset();
+        try
+        {
+            await _sender.Response(Channel)
+                         .Confirm("Typing contest stopped.")
+                         .SendAsync();
+        }
+        catch
+        {
+        }
+
+        return true;
+    }
+
+    public async Task Start()
+    {
+        if (IsActive)
+            return; // can't start running game
+        IsActive = true;
+        CurrentSentence = GetRandomSentence();
+        var i = (int)(CurrentSentence.Length / WORD_VALUE * 1.7f);
+        try
+        {
+            await _sender.Response(Channel)
+                         .Confirm(
+                             $":clock2: Next contest will last for {i} seconds. Type the bolded text as fast as you can.")
+                         .SendAsync();
+
+
+            var time = _options.StartTime;
+
+            var msg = await _sender.Response(Channel).Confirm($"Starting new typing contest in **{time}**...").SendAsync();
+
+            do
+            {
+                await Task.Delay(2000);
+                time -= 2;
+                try { await msg.ModifyAsync(m => m.Content = $"Starting new typing contest in **{time}**.."); }
+                catch { }
+            } while (time > 2);
+
+            await msg.ModifyAsync(m =>
+            {
+                m.Content = CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture);
+            });
+            _sw.Start();
+            HandleAnswers();
+
+            while (i > 0)
+            {
+                await Task.Delay(1000);
+                i--;
+                if (!IsActive)
+                    return;
+            }
+        }
+        catch { }
+        finally
+        {
+            await Stop();
+        }
+    }
+
+    public string GetRandomSentence()
+    {
+        if (_games.TypingArticles.Any())
+            return _games.TypingArticles[new EllieRandom().Next(0, _games.TypingArticles.Count)].Text;
+        return $"No typing articles found. Use {_prefix}typeadd command to add a new article for typing.";
+    }
+
+    private void HandleAnswers()
+        => _client.MessageReceived += AnswerReceived;
+
+    private Task AnswerReceived(SocketMessage imsg)
+    {
+        _ = Task.Run(async () =>
+        {
+            try
+            {
+                if (imsg.Author.IsBot)
+                    return;
+                if (imsg is not SocketUserMessage msg)
+                    return;
+
+                if (Channel is null || Channel.Id != msg.Channel.Id)
+                    return;
+
+                var guess = msg.Content;
+
+                var distance = CurrentSentence.LevenshteinDistance(guess);
+                var decision = Judge(distance, guess.Length);
+                if (decision && !_finishedUserIds.Contains(msg.Author.Id))
+                {
+                    var elapsed = _sw.Elapsed;
+                    var wpm = CurrentSentence.Length / WORD_VALUE / elapsed.TotalSeconds * 60;
+                    _finishedUserIds.Add(msg.Author.Id);
+
+                    var embed = _sender.CreateEmbed()
+                                .WithOkColor()
+                                .WithTitle($"{msg.Author} finished the race!")
+                                .AddField("Place", $"#{_finishedUserIds.Count}", true)
+                                .AddField("WPM", $"{wpm:F1} *[{elapsed.TotalSeconds:F2}sec]*", true)
+                                .AddField("Errors", distance.ToString(), true);
+                    
+                    await _sender.Response(Channel)
+                                 .Embed(embed)
+                                 .SendAsync();
+
+                    if (_finishedUserIds.Count % 4 == 0)
+                    {
+                        await _sender.Response(Channel)
+                                     .Confirm(
+                                         $"""
+                                          :exclamation: A lot of people finished, here is the text for those still typing:
+
+                                          **{Format.Sanitize(CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture)).SanitizeMentions(true)}**
+                                          """)
+                                     .SendAsync();
+                    }
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "Error receiving typing game answer: {ErrorMessage}", ex.Message);
+            }
+        });
+        return Task.CompletedTask;
+    }
+
+    private static bool Judge(int errors, int textLength)
+        => errors <= textLength / 25;
+
+    public class Options : IEllieCommandOptions
+    {
+        [Option('s',
+            "start-time",
+            Default = 5,
+            Required = false,
+            HelpText = "How long does it take for the race to start. Default 5.")]
+        public int StartTime { get; set; } = 5;
+
+        public void NormalizeOptions()
+        {
+            if (StartTime is < 3 or > 30)
+                StartTime = 5;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs b/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs
new file mode 100644
index 0000000..fa8070d
--- /dev/null
+++ b/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs
@@ -0,0 +1,307 @@
+#nullable disable
+using CommandLine;
+using System.Text;
+
+namespace EllieBot.Modules.Games.Common;
+
+public class TicTacToe
+{
+    public event Action<TicTacToe> OnEnded;
+    private readonly ITextChannel _channel;
+    private readonly IGuildUser[] _users;
+    private readonly int?[,] _state;
+    private Phase phase;
+    private int curUserIndex;
+    private readonly SemaphoreSlim _moveLock;
+
+    private IGuildUser winner;
+
+    private readonly string[] _numbers =
+    [
+        ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:"
+    ];
+
+    private IUserMessage previousMessage;
+    private Timer timeoutTimer;
+    private readonly IBotStrings _strings;
+    private readonly DiscordSocketClient _client;
+    private readonly Options _options;
+    private readonly IMessageSenderService _sender;
+
+    public TicTacToe(
+        IBotStrings strings,
+        DiscordSocketClient client,
+        ITextChannel channel,
+        IGuildUser firstUser,
+        Options options,
+        IMessageSenderService sender)
+    {
+        _channel = channel;
+        _strings = strings;
+        _client = client;
+        _options = options;
+        _sender = sender;
+
+        _users = [firstUser, null];
+        _state = new int?[,] { { null, null, null }, { null, null, null }, { null, null, null } };
+
+        phase = Phase.Starting;
+        _moveLock = new(1, 1);
+    }
+
+    private string GetText(LocStr key)
+        => _strings.GetText(key, _channel.GuildId);
+
+    public string GetState()
+    {
+        var sb = new StringBuilder();
+        for (var i = 0; i < _state.GetLength(0); i++)
+        {
+            for (var j = 0; j < _state.GetLength(1); j++)
+            {
+                sb.Append(_state[i, j] is null ? _numbers[(i * 3) + j] : GetIcon(_state[i, j]));
+                if (j < _state.GetLength(1) - 1)
+                    sb.Append("┃");
+            }
+
+            if (i < _state.GetLength(0) - 1)
+                sb.AppendLine("\n──────────");
+        }
+
+        return sb.ToString();
+    }
+
+    public EmbedBuilder GetEmbed(string title = null)
+    {
+        var embed = _sender.CreateEmbed()
+                       .WithOkColor()
+                       .WithDescription(Environment.NewLine + GetState())
+                       .WithAuthor(GetText(strs.vs(_users[0], _users[1])));
+
+        if (!string.IsNullOrWhiteSpace(title))
+            embed.WithTitle(title);
+
+        if (winner is null)
+        {
+            if (phase == Phase.Ended)
+                embed.WithFooter(GetText(strs.ttt_no_moves));
+            else
+                embed.WithFooter(GetText(strs.ttt_users_move(_users[curUserIndex])));
+        }
+        else
+            embed.WithFooter(GetText(strs.ttt_has_won(winner)));
+
+        return embed;
+    }
+
+    private static string GetIcon(int? val)
+    {
+        switch (val)
+        {
+            case 0:
+                return "❌";
+            case 1:
+                return "⭕";
+            case 2:
+                return "❎";
+            case 3:
+                return "🅾";
+            default:
+                return "⬛";
+        }
+    }
+
+    public async Task Start(IGuildUser user)
+    {
+        if (phase is Phase.Started or Phase.Ended)
+        {
+            await _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_already_running)).SendAsync();
+            return;
+        }
+
+        if (_users[0] == user)
+        {
+            await _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_against_yourself)).SendAsync();
+            return;
+        }
+
+        _users[1] = user;
+
+        phase = Phase.Started;
+
+        timeoutTimer = new(async _ =>
+            {
+                await _moveLock.WaitAsync();
+                try
+                {
+                    if (phase == Phase.Ended)
+                        return;
+
+                    phase = Phase.Ended;
+                    if (_users[1] is not null)
+                    {
+                        winner = _users[curUserIndex ^= 1];
+                        var del = previousMessage?.DeleteAsync();
+                        try
+                        {
+                            await _sender.Response(_channel).Embed(GetEmbed(GetText(strs.ttt_time_expired))).SendAsync();
+                            if (del is not null)
+                                await del;
+                        }
+                        catch { }
+                    }
+
+                    OnEnded?.Invoke(this);
+                }
+                catch { }
+                finally
+                {
+                    _moveLock.Release();
+                }
+            },
+            null,
+            _options.TurnTimer * 1000,
+            Timeout.Infinite);
+
+        _client.MessageReceived += Client_MessageReceived;
+
+
+        previousMessage = await _sender.Response(_channel).Embed(GetEmbed(GetText(strs.game_started))).SendAsync();
+    }
+
+    private bool IsDraw()
+    {
+        for (var i = 0; i < 3; i++)
+        for (var j = 0; j < 3; j++)
+        {
+            if (_state[i, j] is null)
+                return false;
+        }
+
+        return true;
+    }
+
+    private Task Client_MessageReceived(SocketMessage msg)
+    {
+        _ = Task.Run(async () =>
+        {
+            await _moveLock.WaitAsync();
+            try
+            {
+                var curUser = _users[curUserIndex];
+                if (phase == Phase.Ended || msg.Author?.Id != curUser.Id)
+                    return;
+
+                if (int.TryParse(msg.Content, out var index)
+                    && --index >= 0
+                    && index <= 9
+                    && _state[index / 3, index % 3] is null)
+                {
+                    _state[index / 3, index % 3] = curUserIndex;
+
+                    // i'm lazy
+                    if (_state[index / 3, 0] == _state[index / 3, 1] && _state[index / 3, 1] == _state[index / 3, 2])
+                    {
+                        _state[index / 3, 0] = curUserIndex + 2;
+                        _state[index / 3, 1] = curUserIndex + 2;
+                        _state[index / 3, 2] = curUserIndex + 2;
+
+                        phase = Phase.Ended;
+                    }
+                    else if (_state[0, index % 3] == _state[1, index % 3]
+                             && _state[1, index % 3] == _state[2, index % 3])
+                    {
+                        _state[0, index % 3] = curUserIndex + 2;
+                        _state[1, index % 3] = curUserIndex + 2;
+                        _state[2, index % 3] = curUserIndex + 2;
+
+                        phase = Phase.Ended;
+                    }
+                    else if (curUserIndex == _state[0, 0]
+                             && _state[0, 0] == _state[1, 1]
+                             && _state[1, 1] == _state[2, 2])
+                    {
+                        _state[0, 0] = curUserIndex + 2;
+                        _state[1, 1] = curUserIndex + 2;
+                        _state[2, 2] = curUserIndex + 2;
+
+                        phase = Phase.Ended;
+                    }
+                    else if (curUserIndex == _state[0, 2]
+                             && _state[0, 2] == _state[1, 1]
+                             && _state[1, 1] == _state[2, 0])
+                    {
+                        _state[0, 2] = curUserIndex + 2;
+                        _state[1, 1] = curUserIndex + 2;
+                        _state[2, 0] = curUserIndex + 2;
+
+                        phase = Phase.Ended;
+                    }
+
+                    var reason = string.Empty;
+
+                    if (phase == Phase.Ended) // if user won, stop receiving moves
+                    {
+                        reason = GetText(strs.ttt_matched_three);
+                        winner = _users[curUserIndex];
+                        _client.MessageReceived -= Client_MessageReceived;
+                        OnEnded?.Invoke(this);
+                    }
+                    else if (IsDraw())
+                    {
+                        reason = GetText(strs.ttt_a_draw);
+                        phase = Phase.Ended;
+                        _client.MessageReceived -= Client_MessageReceived;
+                        OnEnded?.Invoke(this);
+                    }
+
+                    _ = Task.Run(async () =>
+                    {
+                        var del1 = msg.DeleteAsync();
+                        var del2 = previousMessage?.DeleteAsync();
+                        try { previousMessage = await _sender.Response(_channel).Embed(GetEmbed(reason)).SendAsync(); }
+                        catch { }
+
+                        try { await del1; }
+                        catch { }
+
+                        try
+                        {
+                            if (del2 is not null)
+                                await del2;
+                        }
+                        catch { }
+                    });
+                    curUserIndex ^= 1;
+
+                    timeoutTimer.Change(_options.TurnTimer * 1000, Timeout.Infinite);
+                }
+            }
+            finally
+            {
+                _moveLock.Release();
+            }
+        });
+
+        return Task.CompletedTask;
+    }
+
+    public class Options : IEllieCommandOptions
+    {
+        [Option('t', "turn-timer", Required = false, Default = 15, HelpText = "Turn time in seconds. Default 15.")]
+        public int TurnTimer { get; set; } = 15;
+
+        public void NormalizeOptions()
+        {
+            if (TurnTimer is < 5 or > 60)
+                TurnTimer = 15;
+        }
+    }
+
+    private enum Phase
+    {
+        Starting,
+        Started,
+        Ended
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs b/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs
new file mode 100644
index 0000000..904f8db
--- /dev/null
+++ b/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs
@@ -0,0 +1,54 @@
+#nullable disable
+using EllieBot.Modules.Games.Common;
+using EllieBot.Modules.Games.Services;
+
+namespace EllieBot.Modules.Games;
+
+public partial class Games
+{
+    [Group]
+    public partial class TicTacToeCommands : EllieModule<GamesService>
+    {
+        private readonly SemaphoreSlim _sem = new(1, 1);
+        private readonly DiscordSocketClient _client;
+
+        public TicTacToeCommands(DiscordSocketClient client)
+            => _client = client;
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [EllieOptions<TicTacToe.Options>]
+        public async Task TicTacToe(params string[] args)
+        {
+            var (options, _) = OptionsParser.ParseFrom(new TicTacToe.Options(), args);
+            var channel = (ITextChannel)ctx.Channel;
+
+            await _sem.WaitAsync(1000);
+            try
+            {
+                if (_service.TicTacToeGames.TryGetValue(channel.Id, out var game))
+                {
+                    _ = Task.Run(async () =>
+                    {
+                        await game.Start((IGuildUser)ctx.User);
+                    });
+                    return;
+                }
+
+                game = new(Strings, _client, channel, (IGuildUser)ctx.User, options, _sender);
+                _service.TicTacToeGames.Add(channel.Id, game);
+                await Response().Confirm(strs.ttt_created(ctx.User)).SendAsync();
+
+                game.OnEnded += _ =>
+                {
+                    _service.TicTacToeGames.Remove(channel.Id);
+                    _sem.Dispose();
+                };
+            }
+            finally
+            {
+                _sem.Release();
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs
new file mode 100644
index 0000000..b82dd62
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs
@@ -0,0 +1,22 @@
+namespace EllieBot.Modules.Games.Common.Trivia;
+
+public sealed class DefaultQuestionPool : IQuestionPool
+{
+    private readonly ILocalDataCache _cache;
+    private readonly EllieRandom _rng;
+
+    public DefaultQuestionPool(ILocalDataCache cache)
+    {
+        _cache = cache;
+        _rng = new EllieRandom();
+    }
+    public async Task<TriviaQuestion?> GetQuestionAsync()
+    {
+        var pool = await _cache.GetTriviaQuestionsAsync();
+        
+        if(pool is null or {Length: 0})
+            return default;
+        
+        return new(pool[_rng.Next(0, pool.Length)]);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs
new file mode 100644
index 0000000..636ae16
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs
@@ -0,0 +1,6 @@
+namespace EllieBot.Modules.Games.Common.Trivia;
+
+public interface IQuestionPool
+{
+    Task<TriviaQuestion?> GetQuestionAsync();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs
new file mode 100644
index 0000000..53f56cf
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs
@@ -0,0 +1,32 @@
+namespace EllieBot.Modules.Games.Common.Trivia;
+
+public sealed class PokemonQuestionPool : IQuestionPool
+{
+    public int QuestionsCount => 905; // xd
+    private readonly EllieRandom _rng;
+    private readonly ILocalDataCache _cache;
+
+    public PokemonQuestionPool(ILocalDataCache cache)
+    {
+        _cache = cache;
+        _rng = new EllieRandom();
+    }
+
+    public async Task<TriviaQuestion?> GetQuestionAsync()
+    {
+        var pokes = await _cache.GetPokemonMapAsync();
+
+        if (pokes is null or { Count: 0 })
+            return default;
+            
+        var num = _rng.Next(1, QuestionsCount + 1);
+        return new(new()
+        {
+            Question = "Who's That Pokémon?",
+            Answer = pokes[num].ToTitleCase(),
+            Category = "Pokemon",
+            ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
+            AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png"
+        });
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaCommands.cs b/src/EllieBot/Modules/Games/Trivia/TriviaCommands.cs
new file mode 100644
index 0000000..72b4eb4
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Trivia/TriviaCommands.cs
@@ -0,0 +1,282 @@
+using System.Net;
+using System.Text;
+using EllieBot.Modules.Games.Common.Trivia;
+using EllieBot.Modules.Games.Services;
+
+namespace EllieBot.Modules.Games;
+
+public partial class Games
+{
+    [Group]
+    public partial class TriviaCommands : EllieModule<TriviaGamesService>
+    {
+        private readonly ILocalDataCache _cache;
+        private readonly ICurrencyService _cs;
+        private readonly GamesConfigService _gamesConfig;
+        private readonly DiscordSocketClient _client;
+
+        public TriviaCommands(
+            DiscordSocketClient client,
+            ILocalDataCache cache,
+            ICurrencyService cs,
+            GamesConfigService gamesConfig)
+        {
+            _cache = cache;
+            _cs = cs;
+            _gamesConfig = gamesConfig;
+            _client = client;
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [Priority(0)]
+        [EllieOptions<TriviaOptions>]
+        public async Task Trivia(params string[] args)
+        {
+            var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args);
+
+            var config = _gamesConfig.Data;
+            if (opts.WinRequirement != 0
+                && config.Trivia.MinimumWinReq > 0
+                && config.Trivia.MinimumWinReq > opts.WinRequirement)
+                return;
+
+            var trivia = new TriviaGame(opts, _cache);
+            if (_service.RunningTrivias.TryAdd(ctx.Guild.Id, trivia))
+            {
+                RegisterEvents(trivia);
+                await trivia.RunAsync();
+                return;
+            }
+
+            if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var tg))
+            {
+                await Response().Error(strs.trivia_already_running).SendAsync();
+                await tg.TriggerQuestionAsync();
+            }
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Tl()
+        {
+            if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var trivia))
+            {
+                await trivia.TriggerStatsAsync();
+                return;
+            }
+
+            await Response().Error(strs.trivia_none).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Tq()
+        {
+            var channel = (ITextChannel)ctx.Channel;
+
+            if (_service.RunningTrivias.TryGetValue(channel.Guild.Id, out var trivia))
+            {
+                if (trivia.Stop())
+                {
+                    try
+                    {
+                        await Response()
+                              .Confirm(GetText(strs.trivia_game), GetText(strs.trivia_stopping))
+                              .SendAsync();
+                    }
+                    catch (Exception ex)
+                    {
+                        Log.Warning(ex, "Error sending trivia stopping message");
+                    }
+                }
+
+                return;
+            }
+
+            await Response().Error(strs.trivia_none).SendAsync();
+        }
+
+        private string GetLeaderboardString(TriviaGame tg)
+        {
+            var sb = new StringBuilder();
+
+            foreach (var (id, pts) in tg.GetLeaderboard())
+                sb.AppendLine(GetText(strs.trivia_points(Format.Bold($"<@{id}>"), pts)));
+
+            return sb.ToString();
+        }
+
+        private EmbedBuilder? questionEmbed;
+        private IUserMessage? questionMessage;
+        private bool showHowToQuit;
+
+        private void RegisterEvents(TriviaGame trivia)
+        {
+            trivia.OnQuestion += OnTriviaQuestion;
+            trivia.OnHint += OnTriviaHint;
+            trivia.OnGuess += OnTriviaGuess;
+            trivia.OnEnded += OnTriviaEnded;
+            trivia.OnStats += OnTriviaStats;
+            trivia.OnTimeout += OnTriviaTimeout;
+        }
+
+        private void UnregisterEvents(TriviaGame trivia)
+        {
+            trivia.OnQuestion -= OnTriviaQuestion;
+            trivia.OnHint -= OnTriviaHint;
+            trivia.OnGuess -= OnTriviaGuess;
+            trivia.OnEnded -= OnTriviaEnded;
+            trivia.OnStats -= OnTriviaStats;
+            trivia.OnTimeout -= OnTriviaTimeout;
+        }
+
+        private async Task OnTriviaHint(TriviaGame game, TriviaQuestion question)
+        {
+            try
+            {
+                if (questionMessage is null)
+                {
+                    game.Stop();
+                    return;
+                }
+
+                if (questionEmbed is not null)
+                    await questionMessage.ModifyAsync(m
+                        => m.Embed = questionEmbed.WithFooter(question.GetHint()).Build());
+            }
+            catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden)
+            {
+                Log.Warning("Unable to edit message to show hint. Stopping trivia");
+                game.Stop();
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "Error editing trivia message");
+            }
+        }
+
+        private async Task OnTriviaQuestion(TriviaGame game, TriviaQuestion question)
+        {
+            try
+            {
+                questionEmbed = _sender.CreateEmbed()
+                                   .WithOkColor()
+                                   .WithTitle(GetText(strs.trivia_game))
+                                   .AddField(GetText(strs.category), question.Category)
+                                   .AddField(GetText(strs.question), question.Question);
+
+                showHowToQuit = !showHowToQuit;
+                if (showHowToQuit)
+                    questionEmbed.WithFooter(GetText(strs.trivia_quit($"{prefix}tq")));
+
+                if (Uri.IsWellFormedUriString(question.ImageUrl, UriKind.Absolute))
+                    questionEmbed.WithImageUrl(question.ImageUrl);
+
+                questionMessage = await Response().Embed(questionEmbed).SendAsync();
+            }
+            catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound
+                                               or HttpStatusCode.Forbidden
+                                               or HttpStatusCode.BadRequest)
+            {
+                Log.Warning("Unable to send trivia questions. Stopping immediately");
+                game.Stop();
+                throw;
+            }
+        }
+
+        private async Task OnTriviaTimeout(TriviaGame _, TriviaQuestion question)
+        {
+            try
+            {
+                var embed = _sender.CreateEmbed()
+                               .WithErrorColor()
+                               .WithTitle(GetText(strs.trivia_game))
+                               .WithDescription(GetText(strs.trivia_times_up(Format.Bold(question.Answer))));
+
+                if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute))
+                    embed.WithImageUrl(question.AnswerImageUrl);
+
+                await Response().Embed(embed).SendAsync();
+            }
+            catch
+            {
+                // ignored
+            }
+        }
+
+        private async Task OnTriviaStats(TriviaGame game)
+        {
+            try
+            {
+                await Response().Confirm(GetText(strs.leaderboard), GetLeaderboardString(game)).SendAsync();
+            }
+            catch
+            {
+                // ignored
+            }
+        }
+
+        private async Task OnTriviaEnded(TriviaGame game)
+        {
+            try
+            {
+                await Response().Embed(_sender.CreateEmbed()
+                                    .WithOkColor()
+                                    .WithAuthor(GetText(strs.trivia_ended))
+                                    .WithTitle(GetText(strs.leaderboard))
+                                    .WithDescription(GetLeaderboardString(game))).SendAsync();
+            }
+            catch
+            {
+                // ignored
+            }
+            finally
+            {
+                _service.RunningTrivias.TryRemove(ctx.Guild.Id, out _);
+            }
+
+            UnregisterEvents(game);
+        }
+
+        private async Task OnTriviaGuess(
+            TriviaGame _,
+            TriviaUser user,
+            TriviaQuestion question,
+            bool isWin)
+        {
+            try
+            {
+                var embed = _sender.CreateEmbed()
+                               .WithOkColor()
+                               .WithTitle(GetText(strs.trivia_game))
+                               .WithDescription(GetText(strs.trivia_win(user.Name,
+                                   Format.Bold(question.Answer))));
+
+                if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute))
+                    embed.WithImageUrl(question.AnswerImageUrl);
+
+
+                if (isWin)
+                {
+                    await Response().Embed(embed).SendAsync();
+
+                    var reward = _gamesConfig.Data.Trivia.CurrencyReward;
+                    if (reward > 0)
+                        await _cs.AddAsync(user.Id, reward, new("trivia", "win"));
+
+                    return;
+                }
+
+                embed.WithDescription(GetText(strs.trivia_guess(user.Name,
+                    Format.Bold(question.Answer))));
+
+                await Response().Embed(embed).SendAsync();
+            }
+            catch
+            {
+                // ignored
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs b/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs
new file mode 100644
index 0000000..07c4ab4
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs
@@ -0,0 +1,228 @@
+using System.Threading.Channels;
+using Exception = System.Exception;
+
+namespace EllieBot.Modules.Games.Common.Trivia;
+
+public sealed class TriviaGame
+{
+    private readonly TriviaOptions _opts;
+
+
+    private readonly IQuestionPool _questionPool;
+
+    #region Events
+
+    public event Func<TriviaGame, TriviaQuestion, Task> OnQuestion = static delegate { return Task.CompletedTask; };
+    public event Func<TriviaGame, TriviaQuestion, Task> OnHint = static delegate { return Task.CompletedTask; };
+    public event Func<TriviaGame, Task> OnStats = static delegate { return Task.CompletedTask; };
+
+    public event Func<TriviaGame, TriviaUser, TriviaQuestion, bool, Task> OnGuess = static delegate
+    {
+        return Task.CompletedTask;
+    };
+
+    public event Func<TriviaGame, TriviaQuestion, Task> OnTimeout = static delegate { return Task.CompletedTask; };
+    public event Func<TriviaGame, Task> OnEnded = static delegate { return Task.CompletedTask; };
+
+    #endregion
+
+    private bool _isStopped;
+
+    public TriviaQuestion? CurrentQuestion { get; set; }
+
+
+    private readonly ConcurrentDictionary<ulong, int> _users = new();
+
+    private readonly Channel<(TriviaUser User, string Input)> _inputs
+        = Channel.CreateUnbounded<(TriviaUser, string)>(new UnboundedChannelOptions
+        {
+            AllowSynchronousContinuations = true,
+            SingleReader = true,
+            SingleWriter = false,
+        });
+
+    public TriviaGame(TriviaOptions options, ILocalDataCache cache)
+    {
+        _opts = options;
+
+        _questionPool = _opts.IsPokemon
+            ? new PokemonQuestionPool(cache)
+            : new DefaultQuestionPool(cache);
+    }
+
+    public async Task RunAsync()
+    {
+        await GameLoop();
+    }
+
+    private async Task GameLoop()
+    {
+        Task TimeOutFactory()
+            => Task.Delay(_opts.QuestionTimer * 1000 / 2);
+
+        var errorCount = 0;
+        var inactivity = 0;
+
+        // loop until game is stopped
+        // each iteration is one round
+        var firstRun = true;
+        try
+        {
+            while (!_isStopped)
+            {
+                if (errorCount >= 5)
+                {
+                    Log.Warning("Trivia errored 5 times and will quit");
+                    break;
+                }
+
+                // wait for 3 seconds before posting the next question
+                if (firstRun)
+                {
+                    firstRun = false;
+                }
+                else
+                {
+                    await Task.Delay(3000);
+                }
+
+                var maybeQuestion = await _questionPool.GetQuestionAsync();
+
+                if (maybeQuestion is not { } question)
+                {
+                    // if question is null (ran out of question, or other bugg ) - stop
+                    break;
+                }
+
+                CurrentQuestion = question;
+                try
+                {
+                    // clear out all of the past guesses
+                    while (_inputs.Reader.TryRead(out _))
+                    {
+                    }
+
+                    await OnQuestion(this, question);
+                }
+                catch (Exception ex)
+                {
+                    Log.Warning(ex, "Error executing OnQuestion: {Message}", ex.Message);
+                    errorCount++;
+                    continue;
+                }
+
+
+                // just keep looping through user inputs until someone guesses the answer
+                // or the timer expires
+                var halfGuessTimerTask = TimeOutFactory();
+                var hintSent = false;
+                var guessed = false;
+                while (true)
+                {
+                    using var readCancel = new CancellationTokenSource();
+                    var readTask = _inputs.Reader.ReadAsync(readCancel.Token).AsTask();
+
+                    // wait for either someone to attempt to guess
+                    // or for timeout
+                    var task = await Task.WhenAny(readTask, halfGuessTimerTask);
+
+                    // if the task which completed is the timeout task
+                    if (task == halfGuessTimerTask)
+                    {
+                        readCancel.Cancel();
+
+                        // if hint is already sent, means time expired
+                        // break (end the round)
+                        if (hintSent)
+                            break;
+
+                        // else, means half time passed, send a hint
+                        hintSent = true;
+                        // start a new countdown of the same length
+                        halfGuessTimerTask = TimeOutFactory();
+                        if (!_opts.NoHint)
+                        {
+                            // send a hint out
+                            await OnHint(this, question);
+                        }
+
+                        continue;
+                    }
+
+                    // otherwise, read task is successful, and we're gonna
+                    // get the user input data
+                    var (user, input) = await readTask;
+
+                    // check the guess
+                    if (question.IsAnswerCorrect(input))
+                    {
+                        // add 1 point to the user
+                        var val = _users.AddOrUpdate(user.Id, 1, (_, points) => ++points);
+                        guessed = true;
+
+                        // reset inactivity counter
+                        inactivity = 0;
+                        errorCount = 0;
+
+                        var isWin = false;
+                        // if user won the game, tell the game to stop
+                        if (_opts.WinRequirement != 0 && val >= _opts.WinRequirement)
+                        {
+                            _isStopped = true;
+                            isWin = true;
+                        }
+
+                        // call onguess
+                        await OnGuess(this, user, question, isWin);
+                        break;
+                    }
+                }
+
+                if (!guessed)
+                {
+                    await OnTimeout(this, question);
+
+                    if (_opts.Timeout != 0 && ++inactivity >= _opts.Timeout)
+                    {
+                        Log.Information("Trivia game is stopping due to inactivity");
+                        break;
+                    }
+                }
+            }
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Fatal error in trivia game: {ErrorMessage}", ex.Message);
+        }
+        finally
+        {
+            // make sure game is set as ended
+            _isStopped = true;
+            _ = OnEnded(this);
+        }
+    }
+
+    public IReadOnlyList<(ulong User, int points)> GetLeaderboard()
+        => _users.Select(x => (x.Key, x.Value)).ToArray();
+
+    public ValueTask InputAsync(TriviaUser user, string input)
+        => _inputs.Writer.WriteAsync((user, input));
+
+    public bool Stop()
+    {
+        var isStopped = _isStopped;
+        _isStopped = true;
+        return !isStopped;
+    }
+
+    public async ValueTask TriggerStatsAsync()
+    {
+        await OnStats(this);
+    }
+
+    public async Task TriggerQuestionAsync()
+    {
+        if (CurrentQuestion is TriviaQuestion q)
+            await OnQuestion(this, q);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs b/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs
new file mode 100644
index 0000000..6fc4ab6
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs
@@ -0,0 +1,37 @@
+#nullable disable
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Modules.Games.Common.Trivia;
+
+namespace EllieBot.Modules.Games;
+
+public sealed class TriviaGamesService : IReadyExecutor, IEService
+{
+    private readonly DiscordSocketClient _client;
+    public ConcurrentDictionary<ulong, TriviaGame> RunningTrivias { get; } = new();
+
+    public TriviaGamesService(DiscordSocketClient client)
+    {
+        _client = client;
+    }
+    
+    public Task OnReadyAsync()
+    {
+        _client.MessageReceived += OnMessageReceived;
+
+        return Task.CompletedTask;
+    }
+
+    private async Task OnMessageReceived(SocketMessage msg)
+    {
+        if (msg.Author.IsBot)
+            return;
+
+        var umsg = msg as SocketUserMessage;
+
+        if (umsg?.Channel is not IGuildChannel gc)
+            return;
+
+        if (RunningTrivias.TryGetValue(gc.GuildId, out var tg))
+            await tg.InputAsync(new(umsg.Author.Mention, umsg.Author.Id), umsg.Content);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs b/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs
new file mode 100644
index 0000000..47bdc0b
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs
@@ -0,0 +1,44 @@
+#nullable disable
+using CommandLine;
+
+namespace EllieBot.Modules.Games.Common.Trivia;
+
+public class TriviaOptions : IEllieCommandOptions
+{
+    [Option('p', "pokemon", Required = false, Default = false, HelpText = "Whether it's 'Who's that pokemon?' trivia.")]
+    public bool IsPokemon { get; set; } = false;
+
+    [Option("nohint", Required = false, Default = false, HelpText = "Don't show any hints.")]
+    public bool NoHint { get; set; } = false;
+
+    [Option('w',
+        "win-req",
+        Required = false,
+        Default = 10,
+        HelpText = "Winning requirement. Set 0 for an infinite game. Default 10.")]
+    public int WinRequirement { get; set; } = 10;
+
+    [Option('q',
+        "question-timer",
+        Required = false,
+        Default = 30,
+        HelpText = "How long until the question ends. Default 30.")]
+    public int QuestionTimer { get; set; } = 30;
+
+    [Option('t',
+        "timeout",
+        Required = false,
+        Default = 10,
+        HelpText = "Number of questions of inactivity in order stop. Set 0 for never. Default 10.")]
+    public int Timeout { get; set; } = 10;
+
+    public void NormalizeOptions()
+    {
+        if (WinRequirement < 0)
+            WinRequirement = 10;
+        if (QuestionTimer is < 10 or > 300)
+            QuestionTimer = 30;
+        if (Timeout is < 0 or > 20)
+            Timeout = 10;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs b/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs
new file mode 100644
index 0000000..299c762
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs
@@ -0,0 +1,115 @@
+#nullable disable
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Modules.Games.Common.Trivia;
+
+public class TriviaQuestion
+{
+    public const int MAX_STRING_LENGTH = 22;
+
+    //represents the min size to judge levDistance with
+    private static readonly HashSet<Tuple<int, int>> _strictness =
+    [
+        new(9, 0),
+        new(14, 1),
+        new(19, 2),
+        new(22, 3)
+    ];
+
+    public string Category
+        => _qModel.Category;
+
+    public string Question
+        => _qModel.Question;
+
+    public string ImageUrl
+        => _qModel.ImageUrl;
+
+    public string AnswerImageUrl
+        => _qModel.AnswerImageUrl ?? ImageUrl;
+
+    public string Answer
+        => _qModel.Answer;
+
+    public string CleanAnswer
+        => cleanAnswer ?? (cleanAnswer = Clean(Answer));
+
+    private string cleanAnswer;
+    private readonly TriviaQuestionModel _qModel;
+
+    public TriviaQuestion(TriviaQuestionModel qModel)
+    {
+        _qModel = qModel;
+    }
+
+    public string GetHint()
+        => Scramble(Answer);
+
+    public bool IsAnswerCorrect(string guess)
+    {
+        if (Answer.Equals(guess, StringComparison.InvariantCulture))
+            return true;
+        var cleanGuess = Clean(guess);
+        if (CleanAnswer.Equals(cleanGuess, StringComparison.InvariantCulture))
+            return true;
+
+        var levDistanceClean = CleanAnswer.LevenshteinDistance(cleanGuess);
+        var levDistanceNormal = Answer.LevenshteinDistance(guess);
+        return JudgeGuess(CleanAnswer.Length, cleanGuess.Length, levDistanceClean)
+               || JudgeGuess(Answer.Length, guess.Length, levDistanceNormal);
+    }
+
+    private static bool JudgeGuess(int guessLength, int answerLength, int levDistance)
+    {
+        foreach (var level in _strictness)
+        {
+            if (guessLength <= level.Item1 || answerLength <= level.Item1)
+            {
+                if (levDistance <= level.Item2)
+                    return true;
+                return false;
+            }
+        }
+
+        return false;
+    }
+
+    private static string Clean(string str)
+    {
+        str = " " + str.ToLowerInvariant() + " ";
+        str = Regex.Replace(str, @"\s+", " ");
+        str = Regex.Replace(str, @"[^\w\d\s]", "");
+        //Here's where custom modification can be done
+        str = Regex.Replace(str, @"\s(a|an|the|of|in|for|to|as|at|be)\s", " ");
+        //End custom mod and cleanup whitespace
+        str = Regex.Replace(str, @"^\s+", "");
+        str = Regex.Replace(str, @"\s+$", "");
+        //Trim the really long answers
+        str = str.Length <= MAX_STRING_LENGTH ? str : str[..MAX_STRING_LENGTH];
+        return str;
+    }
+
+    private static string Scramble(string word)
+    {
+        var letters = word.ToCharArray();
+        var count = 0;
+        for (var i = 0; i < letters.Length; i++)
+        {
+            if (letters[i] == ' ')
+                continue;
+
+            count++;
+            if (count <= letters.Length / 5)
+                continue;
+
+            if (count % 3 == 0)
+                continue;
+
+            if (letters[i] != ' ')
+                letters[i] = '_';
+        }
+
+        return string.Join(" ",
+            new string(letters).Replace(" ", " \u2000", StringComparison.InvariantCulture).AsEnumerable());
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs b/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs
new file mode 100644
index 0000000..b61e827
--- /dev/null
+++ b/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs
@@ -0,0 +1,3 @@
+namespace EllieBot.Modules.Games.Common.Trivia;
+
+public record class TriviaUser(string Name, ulong Id);
\ No newline at end of file