diff --git a/src/EllieBot/Modules/Xp/Db/UserXpBatch.cs b/src/EllieBot/Modules/Xp/Db/UserXpBatch.cs
new file mode 100644
index 0000000..4ecffe7
--- /dev/null
+++ b/src/EllieBot/Modules/Xp/Db/UserXpBatch.cs
@@ -0,0 +1,14 @@
+#nullable disable warnings
+using System.ComponentModel.DataAnnotations;
+
+namespace EllieBot.Modules.Xp.Services;
+
+public sealed class UserXpBatch
+{
+    [Key]
+    public ulong UserId { get; set; }
+
+    public ulong GuildId { get; set; }
+    public string Username { get; set; } = string.Empty;
+    public string AvatarId { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Xp/_common/db/XpShopOwnedItem.cs b/src/EllieBot/Modules/Xp/Db/XpShopOwnedItem.cs
similarity index 100%
rename from src/EllieBot/Modules/Xp/_common/db/XpShopOwnedItem.cs
rename to src/EllieBot/Modules/Xp/Db/XpShopOwnedItem.cs
diff --git a/src/EllieBot/Modules/Xp/Xp.cs b/src/EllieBot/Modules/Xp/Xp.cs
index eee259c..3e6801a 100644
--- a/src/EllieBot/Modules/Xp/Xp.cs
+++ b/src/EllieBot/Modules/Xp/Xp.cs
@@ -31,11 +31,13 @@ public partial class Xp : EllieModule<XpService>
 
     private readonly DownloadTracker _tracker;
     private readonly ICurrencyProvider _gss;
+    private readonly XpTemplateService _templateService;
 
-    public Xp(DownloadTracker tracker, ICurrencyProvider gss)
+    public Xp(DownloadTracker tracker, ICurrencyProvider gss, XpTemplateService templateService)
     {
         _tracker = tracker;
         _gss = gss;
+        _templateService = templateService;
     }
 
     [Cmd]
@@ -325,7 +327,7 @@ public partial class Xp : EllieModule<XpService>
     [OwnerOnly]
     public async Task XpTemplateReload()
     {
-        _service.ReloadXpTemplate();
+        _templateService.ReloadXpTemplate();
         await Task.Delay(1000);
         await Response().Confirm(strs.template_reloaded).SendAsync();
     }
diff --git a/src/EllieBot/Modules/Xp/XpConfig.cs b/src/EllieBot/Modules/Xp/XpConfig.cs
index 7b973e4..cb0e120 100644
--- a/src/EllieBot/Modules/Xp/XpConfig.cs
+++ b/src/EllieBot/Modules/Xp/XpConfig.cs
@@ -10,25 +10,19 @@ namespace EllieBot.Modules.Xp;
 public sealed partial class XpConfig : ICloneable<XpConfig>
 {
     [Comment("""DO NOT CHANGE""")]
-    public int Version { get; set; } = 7;
+    public int Version { get; set; } = 10;
 
     [Comment("""How much XP will the users receive per message""")]
-    public int XpPerMessage { get; set; } = 3;
+    public int TextXpPerMessage { get; set; } = 3;
 
     [Comment("""How often can the users receive XP, in seconds""")]
-    public float MessageXpCooldown { get; set; } = 300;
+    public int TextXpCooldown { get; set; } = 300;
 
     [Comment("""Amount of xp users gain from posting an image""")]
-    public int XpFromImage { get; set; } = 0;
+    public int TextXpFromImage { get; set; } = 3;
 
     [Comment("""Average amount of xp earned per minute in VC""")]
-    public double VoiceXpPerMinute { get; set; } = 0;
-
-    [Comment("""The maximum amount of minutes the bot will keep track of a user in a voice channel""")]
-    public int VoiceMaxMinutes { get; set; } = 720;
-    
-    [Comment("""The amount of currency users will receive for each point of global xp that they earn""")]
-    public float CurrencyPerXp { get; set; } = 0;
+    public int VoiceXpPerMinute { get; set; } = 3;
 
     [Comment("""Xp Shop config""")]
     public ShopConfig Shop { get; set; } = new();
@@ -36,44 +30,44 @@ public sealed partial class XpConfig : ICloneable<XpConfig>
     public sealed class ShopConfig
     {
         [Comment("""
-            Whether the xp shop is enabled
-            True -> Users can access the xp shop using .xpshop command
-            False -> Users can't access the xp shop
-            """)]
+                 Whether the xp shop is enabled
+                 True -> Users can access the xp shop using .xpshop command
+                 False -> Users can't access the xp shop
+                 """)]
         public bool IsEnabled { get; set; } = false;
 
         [Comment("""
-            Which patron tier do users need in order to use the .xpshop bgs command
-            Leave at 'None' if patron system is disabled or you don't want any restrictions
-            """)]
+                 Which patron tier do users need in order to use the .xpshop bgs command
+                 Leave at 'None' if patron system is disabled or you don't want any restrictions
+                 """)]
         public PatronTier BgsTierRequirement { get; set; } = PatronTier.None;
-        
+
         [Comment("""
-            Which patron tier do users need in order to use the .xpshop frames command
-            Leave at 'None' if patron system is disabled or you don't want any restrictions
-            """)]
+                 Which patron tier do users need in order to use the .xpshop frames command
+                 Leave at 'None' if patron system is disabled or you don't want any restrictions
+                 """)]
         public PatronTier FramesTierRequirement { get; set; } = PatronTier.None;
-        
+
         [Comment("""
-            Frames available for sale. Keys are unique IDs.
-            Do not change keys as they are not publicly visible. Only change properties (name, price, id)
-            Removing a key which previously existed means that all previous purchases will also be unusable.
-            To remove an item from the shop, but keep previous purchases, set the price to -1
-            """)]
+                 Frames available for sale. Keys are unique IDs.
+                 Do not change keys as they are not publicly visible. Only change properties (name, price, id)
+                 Removing a key which previously existed means that all previous purchases will also be unusable.
+                 To remove an item from the shop, but keep previous purchases, set the price to -1
+                 """)]
         public Dictionary<string, ShopItemInfo>? Frames { get; set; } = new()
         {
-            {"default", new() {Name = "No frame", Price = 0, Url = string.Empty}}
+            { "default", new() { Name = "No frame", Price = 0, Url = string.Empty } }
         };
 
         [Comment("""
-            Backgrounds available for sale. Keys are unique IDs. 
-            Do not change keys as they are not publicly visible. Only change properties (name, price, id)
-            Removing a key which previously existed means that all previous purchases will also be unusable.
-            To remove an item from the shop, but keep previous purchases, set the price to -1
-            """)]
+                 Backgrounds available for sale. Keys are unique IDs. 
+                 Do not change keys as they are not publicly visible. Only change properties (name, price, id)
+                 Removing a key which previously existed means that all previous purchases will also be unusable.
+                 To remove an item from the shop, but keep previous purchases, set the price to -1
+                 """)]
         public Dictionary<string, ShopItemInfo>? Bgs { get; set; } = new()
         {
-            {"default", new() {Name = "Default Background", Price = 0, Url = string.Empty}}
+            { "default", new() { Name = "Default Background", Price = 0, Url = string.Empty } }
         };
     }
 
@@ -81,16 +75,17 @@ public sealed partial class XpConfig : ICloneable<XpConfig>
     {
         [Comment("""Visible name of the item""")]
         public string Name { get; set; }
-        
-        [Comment("""Price of the item. Set to -1 if you no longer want to sell the item but want the users to be able to keep their old purchase""")]
+
+        [Comment(
+            """Price of the item. Set to -1 if you no longer want to sell the item but want the users to be able to keep their old purchase""")]
         public int Price { get; set; }
-        
+
         [Comment("""Direct url to the .png image which will be applied to the user's XP card""")]
         public string Url { get; set; }
-        
+
         [Comment("""Optional preview url which will show instead of the real URL in the shop """)]
         public string Preview { get; set; }
-        
+
         [Comment("""Optional description of the item""")]
         public string Desc { get; set; }
     }
diff --git a/src/EllieBot/Modules/Xp/XpConfigService.cs b/src/EllieBot/Modules/Xp/XpConfigService.cs
index bfb46c7..1f1eb3d 100644
--- a/src/EllieBot/Modules/Xp/XpConfigService.cs
+++ b/src/EllieBot/Modules/Xp/XpConfigService.cs
@@ -15,24 +15,29 @@ public sealed class XpConfigService : ConfigServiceBase<XpConfig>
         : base(FILE_PATH, serializer, pubSub, _changeKey)
     {
         AddParsedProp("txt.cooldown",
-            conf => conf.MessageXpCooldown,
-            float.TryParse,
+            conf => conf.TextXpCooldown,
+            int.TryParse,
             (f) => f.ToString("F2"),
             x => x > 0);
-        AddParsedProp("txt.per_msg", conf => conf.XpPerMessage, int.TryParse, ConfigPrinters.ToString, x => x >= 0);
-        AddParsedProp("txt.per_image", conf => conf.XpFromImage, int.TryParse, ConfigPrinters.ToString, x => x > 0);
 
-        AddParsedProp("voice.per_minute",
-            conf => conf.VoiceXpPerMinute,
-            double.TryParse,
+        AddParsedProp("txt.permsg",
+            conf => conf.TextXpPerMessage,
+            int.TryParse,
             ConfigPrinters.ToString,
             x => x >= 0);
-        AddParsedProp("voice.max_minutes",
-            conf => conf.VoiceMaxMinutes,
+
+        AddParsedProp("txt.perimage",
+            conf => conf.TextXpFromImage,
             int.TryParse,
             ConfigPrinters.ToString,
             x => x > 0);
 
+        AddParsedProp("voice.perminute",
+            conf => conf.VoiceXpPerMinute,
+            int.TryParse,
+            ConfigPrinters.ToString,
+            x => x >= 0);
+
         AddParsedProp("shop.is_enabled",
             conf => conf.Shop.IsEnabled,
             bool.TryParse,
@@ -43,21 +48,11 @@ public sealed class XpConfigService : ConfigServiceBase<XpConfig>
 
     private void Migrate()
     {
-        if (data.Version < 2)
+        if (data.Version < 10)
         {
             ModifyConfig(c =>
             {
-                c.Version = 2;
-                c.XpFromImage = 0;
-            });
-        }
-        
-        if (data.Version < 7)
-        {
-            ModifyConfig(c =>
-            {
-                c.Version = 7;
-                c.MessageXpCooldown *= 60;
+                c.Version = 10;
             });
         }
     }
diff --git a/src/EllieBot/Modules/Xp/XpService.cs b/src/EllieBot/Modules/Xp/XpService.cs
index 54884c3..17072ad 100644
--- a/src/EllieBot/Modules/Xp/XpService.cs
+++ b/src/EllieBot/Modules/Xp/XpService.cs
@@ -1,5 +1,4 @@
 #nullable disable warnings
-using System.ComponentModel.DataAnnotations;
 using LinqToDB;
 using Microsoft.EntityFrameworkCore;
 using EllieBot.Common.ModuleBehaviors;
@@ -11,12 +10,13 @@ using SixLabors.ImageSharp.Drawing.Processing;
 using SixLabors.ImageSharp.Formats;
 using SixLabors.ImageSharp.PixelFormats;
 using SixLabors.ImageSharp.Processing;
-using System.Threading.Channels;
 using LinqToDB.Data;
 using LinqToDB.EntityFrameworkCore;
 using LinqToDB.Tools;
+using Microsoft.Extensions.Caching.Memory;
 using EllieBot.Modules.Administration;
 using EllieBot.Modules.Patronage;
+using SixLabors.ImageSharp.Formats.Png;
 using ArgumentOutOfRangeException = System.ArgumentOutOfRangeException;
 using Color = SixLabors.ImageSharp.Color;
 using Exception = System.Exception;
@@ -42,12 +42,11 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
     private XpTemplate _template = new();
     private readonly DiscordSocketClient _client;
 
-    private readonly TypedKey<bool> _xpTemplateReloadKey;
     private readonly IPatronageService _ps;
     private readonly IBotCache _c;
 
-    private readonly Channel<UserXpGainData> _xpGainQueue = Channel.CreateUnbounded<UserXpGainData>();
     private readonly INotifySubscriber _notifySub;
+    private readonly IMemoryCache _memCache;
     private readonly ShardData _shardData;
 
     private readonly QueueRunner _levelUpQueue = new QueueRunner(0, 100);
@@ -65,6 +64,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         IPubSub pubSub,
         IPatronageService ps,
         INotifySubscriber notifySub,
+        IMemoryCache memCache,
         ShardData shardData)
     {
         _db = db;
@@ -76,44 +76,22 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         _xpConfig = xpConfig;
         _pubSub = pubSub;
         _notifySub = notifySub;
+        _memCache = memCache;
         _shardData = shardData;
-        _excludedServers = new();
+        _excludedServers = [];
         _excludedChannels = new();
         _client = client;
-        _xpTemplateReloadKey = new("xp.template.reload");
         _ps = ps;
         _c = c;
 
-        InternalReloadXpTemplate();
-
         if (client.ShardId == 0)
         {
-            _pubSub.Sub(_xpTemplateReloadKey,
-                _ =>
-                {
-                    InternalReloadXpTemplate();
-                    return default;
-                });
         }
-
-        //load settings
-#if !GLOBAL_ELLIE
-        _client.UserVoiceStateUpdated += Client_OnUserVoiceStateUpdated;
-
-        // Scan guilds on startup.
-        _client.GuildAvailable += Client_OnGuildAvailable;
-        foreach (var guild in _client.Guilds)
-            Client_OnGuildAvailable(guild);
-#endif
     }
 
     public async Task OnReadyAsync()
     {
         // initialize ignored
-        ArgumentOutOfRangeException.ThrowIfLessThan(_xpConfig.Data.MessageXpCooldown,
-            1,
-            nameof(_xpConfig.Data.MessageXpCooldown));
-
         await using (var ctx = _db.GetDbContext())
         {
             var xps = await ctx.GetTable<XpSettings>()
@@ -136,7 +114,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
             }
         }
 
-        await Task.WhenAll(UpdateTimer(), _levelUpQueue.RunAsync());
+        await Task.WhenAll(UpdateTimer(), VoiceUpdateTimer(), _levelUpQueue.RunAsync());
 
         return;
 
@@ -158,7 +136,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
 
         async Task UpdateTimer()
         {
-            // todo a bigger loop that runs once every XpTimer
             using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
             while (await timer.WaitForNextTickAsync())
             {
@@ -178,37 +155,58 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
     /// <summary>
     /// The current batch of users that will gain xp
     /// </summary>
-    private readonly ConcurrentHashSet<IGuildUser> _usersBatch = new();
+    private readonly ConcurrentHashSet<IGuildUser> _usersBatch = [];
 
-    private readonly ConcurrentHashSet<IGuildUser> _voiceXPBatch = new();
+    /// <summary>
+    /// The current batch of users that will gain voice xp
+    /// </summary>
+    private readonly ConcurrentHashSet<IGuildUser> _voiceXpBatch = [];
 
     private async Task UpdateVoiceXp()
     {
-        var xpAmount = (int)_xpConfig.Data.VoiceXpPerMinute;
-        var oldBatch = _voiceXPBatch.ToArray();
-        _voiceXPBatch.Clear();
+        var xpAmount = _xpConfig.Data.VoiceXpPerMinute;
+
+        if (xpAmount <= 0)
+            return;
+
+        var oldBatch = _voiceXpBatch.ToArray();
+        _voiceXpBatch.Clear();
         var validUsers = new HashSet<IGuildUser>();
 
         var guilds = _client.Guilds;
 
         foreach (var g in guilds)
-            foreach (var vc in g.VoiceChannels)
-                foreach (var u in vc.ConnectedUsers)
-                    if (!u.IsMuted && !u.IsDeafened
-                                   && vc.ConnectedUsers.Count(x => !x.IsBot) > 1)
-                    {
-                        if (oldBatch.Contains(u))
-                            validUsers.Add(u);
+        {
+            if (IsServerExcluded(g.Id))
+                continue;
 
-                        _voiceXPBatch.Add(u);
-                    }
+            foreach (var vc in g.VoiceChannels)
+            {
+                if (!IsVoiceChannelActive(vc))
+                    continue;
+
+                if (IsChannelExcluded(vc))
+                    continue;
+
+                foreach (var u in vc.ConnectedUsers)
+                {
+                    if (IsServerOrRoleExcluded(u) || !UserParticipatingInVoiceChannel(u))
+                        continue;
+
+                    if (oldBatch.Contains(u))
+                        validUsers.Add(u);
+
+                    _voiceXpBatch.Add(u);
+                }
+            }
+        }
 
         await UpdateXpInternalAsync(validUsers.DistinctBy(x => x.Id).ToArray(), xpAmount);
     }
 
     private async Task UpdateXp()
     {
-        var xpAmount = _xpConfig.Data.XpPerMessage;
+        var xpAmount = _xpConfig.Data.TextXpPerMessage;
         var currentBatch = _usersBatch.ToArray();
         _usersBatch.Clear();
 
@@ -223,7 +221,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         await using var ctx = _db.GetDbContext();
         await using var lctx = ctx.CreateLinqToDBConnection();
 
-        var tempTableName = "xp_batch_" + _shardData.ShardId;
+        var tempTableName = "xptemp_" + Guid.NewGuid().ToString().Replace("-", string.Empty);
         await using var batchTable = await lctx.CreateTempTableAsync<UserXpBatch>(tempTableName);
 
         await batchTable.BulkCopyAsync(currentBatch.Select(x => new UserXpBatch()
@@ -245,48 +243,31 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
                  Xp = UserXpStats.Xp + EXCLUDED.Xp;
              """);
 
-        await lctx.ExecuteAsync(
-            $"""
-             INSERT INTO DiscordUser (UserId, AvatarId, Username, TotalXp)
-             SELECT "{tempTableName}"."UserId", "{tempTableName}"."AvatarId", "{tempTableName}"."Username", {xpAmount}
-             FROM {tempTableName}
-             WHERE TRUE
-             ON CONFLICT (UserId) DO UPDATE 
-             SET 
-                 Username = EXCLUDED.Username,
-                 AvatarId = EXCLUDED.AvatarId,
-                 TotalXp = DiscordUser.TotalXp + {xpAmount};
-             """);
 
-        foreach (var (guildId, users) in currentBatch.GroupBy(x => x.GuildId)
-                     .ToDictionary(x => x.Key, x => x.AsEnumerable()))
+        var updated = await batchTable
+            .InnerJoin(lctx.GetTable<UserXpStats>(),
+                (u, s) => u.GuildId == s.GuildId && u.UserId == s.UserId,
+                (batch, stats) => stats)
+            .ToListAsyncLinqToDB();
+
+        foreach (var u in updated)
         {
-            var userIds = users.Select(x => x.Id).ToArray();
+            var oldStats = new LevelStats(u.Xp - xpAmount);
+            var newStats = new LevelStats(u.Xp);
 
-            var dbStats = await ctx.GetTable<UserXpStats>()
-                .Where(x => x.GuildId == guildId && userIds.Contains(x.UserId))
-                .OrderByDescending(x => x.Xp)
-                .ToArrayAsyncLinqToDB();
+            Log.Information("User {User} xp updated from {OldLevel} to {NewLevel}",
+                u.UserId,
+                oldStats.TotalXp,
+                newStats.TotalXp);
 
-            for (var i = 0; i < dbStats.Length; i++)
+            if (oldStats.Level < newStats.Level)
             {
-                var oldStats = new LevelStats(dbStats[i].Xp - xpAmount);
-                var newStats = new LevelStats(dbStats[i].Xp);
-
-                Log.Information("User {User} xp updated from {OldLevel} to {NewLevel}",
-                    dbStats[i].UserId,
-                    oldStats.TotalXp,
-                    newStats.TotalXp);
-
-                if (oldStats.Level < newStats.Level)
-                {
-                    await _levelUpQueue.EnqueueAsync(NotifyUser(guildId,
-                        0,
-                        dbStats[i].UserId,
-                        true,
-                        oldStats.Level,
-                        newStats.Level));
-                }
+                await _levelUpQueue.EnqueueAsync(NotifyUser(u.GuildId,
+                    0,
+                    u.UserId,
+                    true,
+                    oldStats.Level,
+                    newStats.Level));
             }
         }
     }
@@ -410,50 +391,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         }
     }
 
-    private const string XP_TEMPLATE_PATH = "./data/xp_template.json";
-
-    private void InternalReloadXpTemplate()
-    {
-        try
-        {
-            var settings = new JsonSerializerSettings
-            {
-                ContractResolver = new RequireObjectPropertiesContractResolver()
-            };
-
-            if (!File.Exists(XP_TEMPLATE_PATH))
-            {
-                var newTemp = new XpTemplate();
-                newTemp.Version = 1;
-                File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(newTemp, Formatting.Indented));
-            }
-
-            _template = JsonConvert.DeserializeObject<XpTemplate>(
-                File.ReadAllText(XP_TEMPLATE_PATH),
-                settings)!;
-
-            if (_template.Version < 1)
-            {
-                Log.Warning("Loaded default xp_template.json values as the old one was version 0. "
-                            + "Old one was renamed to xp_template.json.old");
-                File.WriteAllText("./data/xp_template.json.old",
-                    JsonConvert.SerializeObject(_template, Formatting.Indented));
-                _template = new();
-                _template.Version = 1;
-                File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(_template, Formatting.Indented));
-            }
-        }
-        catch (Exception ex)
-        {
-            Log.Error(ex, "xp_template.json is invalid. Loaded default values");
-            _template = new();
-            _template.Version = 1;
-        }
-    }
-
-    public void ReloadXpTemplate()
-        => _pubSub.Pub(_xpTemplateReloadKey, true);
-
     public async Task SetCurrencyReward(ulong guildId, int level, int amount)
     {
         await using var uow = _db.GetDbContext();
@@ -484,7 +421,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
             }
         }
 
-        uow.SaveChanges();
+        await uow.SaveChangesAsync();
     }
 
     public async Task<XpSettings> GetFullXpSettingsFor(ulong guildId)
@@ -586,77 +523,44 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
             .ToArrayAsyncLinqToDB();
     }
 
-    private Task Client_OnGuildAvailable(SocketGuild guild)
+    private bool IsVoiceChannelActive(SocketVoiceChannel channel)
     {
-        Task.Run(async () =>
+        var count = 0;
+        foreach (var user in channel.ConnectedUsers)
         {
-            foreach (var channel in guild.VoiceChannels)
-                await ScanChannelForVoiceXp(channel);
-        });
-
-        return Task.CompletedTask;
-    }
-
-    private async Task Client_OnUserVoiceStateUpdated(SocketUser socketUser, SocketVoiceState before,
-        SocketVoiceState after)
-    {
-        if (socketUser is not SocketGuildUser user || user.IsBot)
-            return;
-
-        if (after.VoiceChannel is not null)
-        {
-            await ScanChannelForVoiceXp(after.VoiceChannel);
-        }
-    }
-
-    private async Task ScanChannelForVoiceXp(SocketVoiceChannel channel)
-    {
-        if (ShouldTrackVoiceChannel(channel))
-        {
-            foreach (var user in channel.ConnectedUsers)
+            if (UserParticipatingInVoiceChannel(user))
             {
-                if (UserParticipatingInVoiceChannel(user) && ShouldTrackXp(user, channel))
-                    await UserJoinedVoiceChannel(user);
+                count++;
+                if (count >= 2)
+                    return true;
             }
         }
+
+        return false;
     }
 
-
-    private bool ShouldTrackVoiceChannel(SocketVoiceChannel channel)
-        => channel.ConnectedUsers.Where(UserParticipatingInVoiceChannel).Take(2).Count() >= 2;
-
-    private bool UserParticipatingInVoiceChannel(SocketGuildUser user)
+    private static bool UserParticipatingInVoiceChannel(SocketGuildUser user)
         => !user.IsDeafened && !user.IsMuted && !user.IsSelfDeafened && !user.IsSelfMuted;
 
-    private TypedKey<long> GetVoiceXpKey(ulong userId)
-        => new($"xp:{_client.CurrentUser.Id}:vc_join:{userId}");
-
-    private async Task UserJoinedVoiceChannel(SocketGuildUser user)
+    private bool IsServerOrRoleExcluded(SocketGuildUser user)
     {
-        var value = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
-
-        await _c.AddAsync(GetVoiceXpKey(user.Id),
-            value,
-            TimeSpan.FromMinutes(1),
-            overwrite: true);
-    }
-
-    private bool ShouldTrackXp(SocketGuildUser user, IMessageChannel channel)
-    {
-        var channelId = channel.Id;
-
-        if (_excludedChannels.TryGetValue(user.Guild.Id, out var chans)
-            && (chans.Contains(channelId)
-                || (channel is SocketThreadChannel tc && chans.Contains(tc.ParentChannel.Id))))
-            return false;
-
         if (_excludedServers.Contains(user.Guild.Id))
-            return false;
+            return true;
 
         if (_excludedRoles.TryGetValue(user.Guild.Id, out var roles) && user.Roles.Any(x => roles.Contains(x.Id)))
-            return false;
+            return true;
 
-        return true;
+        return false;
+    }
+
+    private bool IsChannelExcluded(IGuildChannel channel)
+    {
+        if (_excludedChannels.TryGetValue(channel.Guild.Id, out var chans)
+            && (chans.Contains(channel.Id)
+                || (channel is SocketThreadChannel tc && chans.Contains(tc.ParentChannel.Id))))
+            return true;
+
+        return false;
     }
 
     public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage arg)
@@ -664,27 +568,34 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         if (arg.Author is not SocketGuildUser user || user.IsBot)
             return Task.CompletedTask;
 
+        if (arg.Channel is not IGuildChannel gc)
+            return Task.CompletedTask;
+
         _ = Task.Run(async () =>
         {
-            if (!ShouldTrackXp(user, arg.Channel))
+            if (IsChannelExcluded(gc))
+                return;
+
+            if (IsServerOrRoleExcluded(user))
                 return;
 
             var xpConf = _xpConfig.Data;
             var xp = 0;
             if (arg.Attachments.Any(a => a.Height >= 128 && a.Width >= 128))
-                xp = xpConf.XpFromImage;
+                xp = xpConf.TextXpFromImage;
 
             if (arg.Content.Contains(' ') || arg.Content.Length >= 5)
-                xp = Math.Max(xp, xpConf.XpPerMessage);
+                xp = Math.Max(xp, xpConf.TextXpPerMessage);
 
             if (xp <= 0)
                 return;
 
-            if (!await SetUserRewardedAsync(user.Id))
+            if (!await TryAddUserGainedXpAsync(user.Id, xpConf.TextXpCooldown))
                 return;
 
             _usersBatch.Add(user);
         });
+
         return Task.CompletedTask;
     }
 
@@ -706,7 +617,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
 
         usr.Xp += amount;
 
-        uow.SaveChanges();
+        await uow.SaveChangesAsync();
     }
 
     public bool IsServerExcluded(ulong id)
@@ -717,7 +628,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         if (_excludedRoles.TryGetValue(id, out var val))
             return val.ToArray();
 
-        return Enumerable.Empty<ulong>();
+        return [];
     }
 
     public IEnumerable<ulong> GetExcludedChannels(ulong id)
@@ -725,17 +636,24 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         if (_excludedChannels.TryGetValue(id, out var val))
             return val.ToArray();
 
-        return Enumerable.Empty<ulong>();
+        return [];
     }
 
-    private TypedKey<bool> GetUserRewKey(ulong userId)
-        => new($"xp:{_client.CurrentUser.Id}:user_gain:{userId}");
+    private async Task<bool> TryAddUserGainedXpAsync(ulong userId, int cdInSeconds)
+    {
+        if (cdInSeconds <= 0)
+            return true;
 
-    private async Task<bool> SetUserRewardedAsync(ulong userId)
-        => await _c.AddAsync(GetUserRewKey(userId),
-            true,
-            expiry: TimeSpan.FromSeconds(_xpConfig.Data.MessageXpCooldown),
-            overwrite: false);
+        if (_memCache.TryGetValue(userId, out _))
+            return false;
+
+        using var entry = _memCache.CreateEntry(userId);
+        entry.Value = true;
+
+        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(cdInSeconds);
+
+        return true;
+    }
 
     public async Task<FullUserStats> GetUserStatsAsync(IGuildUser user)
     {
@@ -774,7 +692,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
 
     public async Task<bool> ToggleExcludeRoleAsync(ulong guildId, ulong rId)
     {
-        var roles = _excludedRoles.GetOrAdd(guildId, _ => new());
+        var roles = _excludedRoles.GetOrAdd(guildId, _ => []);
         await using var uow = _db.GetDbContext();
         var xpSetting = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.ExclusionList));
         var excludeObj = new ExcludedItem
@@ -805,7 +723,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
 
     public async Task<bool> ToggleExcludeChannelAsync(ulong guildId, ulong chId)
     {
-        var channels = _excludedChannels.GetOrAdd(guildId, _ => new());
+        var channels = _excludedChannels.GetOrAdd(guildId, _ => []);
         await using var uow = _db.GetDbContext();
         var xpSetting = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.ExclusionList));
         var excludeObj = new ExcludedItem
@@ -837,6 +755,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
     }
 
 
+    private int _lastKnownTemplateHashCode;
+
     public Task<(Stream Image, IImageFormat Format)> GenerateXpImageAsync(FullUserStats stats)
         => Task.Run(async () =>
         {
@@ -848,9 +768,39 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
                 throw new ArgumentNullException(nameof(bgBytes));
             }
 
+            var avatarUrl = stats.User.RealAvatarUrl();
+            byte[] avatarImageData = null;
+
+            if (avatarUrl is not null)
+            {
+                var result = await _c.GetImageDataAsync(avatarUrl);
+                if (!result.TryPickT0(out avatarImageData, out _))
+                {
+                    using (var http = _httpFactory.CreateClient())
+                    {
+                        var avatarData = await http.GetByteArrayAsync(avatarUrl);
+                        using (var tempDraw = Image.Load<Rgba32>(avatarData))
+                        {
+                            tempDraw.Mutate(x => x
+                                .Resize(_template.User.Icon.Size.X, _template.User.Icon.Size.Y)
+                                .ApplyRoundedCorners(Math.Max(_template.User.Icon.Size.X,
+                                                         _template.User.Icon.Size.Y)
+                                                     / 2.0f));
+                            await using (var stream = await tempDraw.ToStreamAsync())
+                            {
+                                avatarImageData = stream.ToArray();
+                            }
+                        }
+                    }
+
+                    await _c.SetImageDataAsync(avatarUrl, avatarImageData);
+                }
+            }
+
             var outlinePen = new SolidPen(Color.Black, 1f);
 
             using var img = Image.Load<Rgba32>(bgBytes);
+
             if (_template.User.Name.Show)
             {
                 var fontSize = (int)(_template.User.Name.FontSize * 0.9);
@@ -874,225 +824,194 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
                         "@" + username,
                         Brushes.Solid(_template.User.Name.Color),
                         outlinePen);
-                });
-            }
-
-            //club name
-
-            if (_template.Club.Name.Show)
-            {
-                var clubName = stats.User.Club?.ToString() ?? "-";
-
-                var clubFont = _fonts.NotoSans.CreateFont(_template.Club.Name.FontSize, FontStyle.Regular);
-
-                img.Mutate(x => x.DrawText(new RichTextOptions(clubFont)
-                {
-                    HorizontalAlignment = HorizontalAlignment.Right,
-                    VerticalAlignment = VerticalAlignment.Top,
-                    FallbackFontFamilies = _fonts.FallBackFonts,
-                    Origin = new(_template.Club.Name.Pos.X + 50, _template.Club.Name.Pos.Y - 8)
-                },
-                    clubName,
-                    Brushes.Solid(_template.Club.Name.Color),
-                    outlinePen));
-            }
-
-            Font GetTruncatedFont(
-                FontFamily fontFamily,
-                int fontSize,
-                FontStyle style,
-                string text,
-                int maxSize)
-            {
-                var font = fontFamily.CreateFont(fontSize, style);
-                var size = TextMeasurer.MeasureSize(text, new(font));
-                var scale = maxSize / size.Width;
-                if (scale < 1)
-                    font = fontFamily.CreateFont(fontSize * scale, style);
-
-                return font;
-            }
 
 
-            if (_template.User.GlobalLevel.Show)
-            {
-                // up to 83 width
+                    //club name
 
-                var globalLevelFont = GetTruncatedFont(
-                    _fonts.NotoSans,
-                    _template.User.GlobalLevel.FontSize,
-                    FontStyle.Bold,
-                    stats.Global.Level.ToString(),
-                    75);
-
-                img.Mutate(x =>
-                {
-                    x.DrawText(stats.Global.Level.ToString(),
-                        globalLevelFont,
-                        _template.User.GlobalLevel.Color,
-                        new(_template.User.GlobalLevel.Pos.X, _template.User.GlobalLevel.Pos.Y)); //level
-                });
-            }
-
-            if (_template.User.GuildLevel.Show)
-            {
-                var guildLevelFont = GetTruncatedFont(
-                    _fonts.NotoSans,
-                    _template.User.GuildLevel.FontSize,
-                    FontStyle.Bold,
-                    stats.Guild.Level.ToString(),
-                    75);
-
-                img.Mutate(x =>
-                {
-                    x.DrawText(stats.Guild.Level.ToString(),
-                        guildLevelFont,
-                        _template.User.GuildLevel.Color,
-                        new(_template.User.GuildLevel.Pos.X, _template.User.GuildLevel.Pos.Y));
-                });
-            }
-
-
-            var global = stats.Global;
-            var guild = stats.Guild;
-
-            //xp bar
-            if (_template.User.Xp.Bar.Show)
-            {
-                var xpPercent = global.LevelXp / (float)global.RequiredXp;
-                DrawXpBar(xpPercent, _template.User.Xp.Bar.Global, img);
-                xpPercent = guild.LevelXp / (float)guild.RequiredXp;
-                DrawXpBar(xpPercent, _template.User.Xp.Bar.Guild, img);
-            }
-
-            if (_template.User.Xp.Global.Show)
-            {
-                img.Mutate(x => x.DrawText(
-                    new RichTextOptions(_fonts.NotoSans.CreateFont(_template.User.Xp.Global.FontSize, FontStyle.Bold))
+                    if (_template.Club.Name.Show)
                     {
-                        HorizontalAlignment = HorizontalAlignment.Center,
-                        VerticalAlignment = VerticalAlignment.Center,
-                        Origin = new(_template.User.Xp.Global.Pos.X, _template.User.Xp.Global.Pos.Y),
-                    },
-                    $"{global.LevelXp}/{global.RequiredXp}",
-                    Brushes.Solid(_template.User.Xp.Global.Color),
-                    outlinePen));
-            }
+                        var clubName = stats.User.Club?.ToString() ?? "-";
 
-            if (_template.User.Xp.Guild.Show)
-            {
-                img.Mutate(x => x.DrawText(
-                    new RichTextOptions(_fonts.NotoSans.CreateFont(_template.User.Xp.Guild.FontSize, FontStyle.Bold))
-                    {
-                        HorizontalAlignment = HorizontalAlignment.Center,
-                        VerticalAlignment = VerticalAlignment.Center,
-                        Origin = new(_template.User.Xp.Guild.Pos.X, _template.User.Xp.Guild.Pos.Y)
-                    },
-                    $"{guild.LevelXp}/{guild.RequiredXp}",
-                    Brushes.Solid(_template.User.Xp.Guild.Color),
-                    outlinePen));
-            }
+                        var clubFont = _fonts.NotoSans.CreateFont(_template.Club.Name.FontSize, FontStyle.Regular);
 
-            var rankPen = new SolidPen(Color.White, 1);
-            //ranking
-            if (_template.User.GlobalRank.Show)
-            {
-                var globalRankStr = stats.GlobalRanking.ToString();
-
-                var globalRankFont = GetTruncatedFont(
-                    _fonts.UniSans,
-                    _template.User.GlobalRank.FontSize,
-                    FontStyle.Bold,
-                    globalRankStr,
-                    68);
-
-                img.Mutate(x => x.DrawText(
-                    new RichTextOptions(globalRankFont)
-                    {
-                        Origin = new(_template.User.GlobalRank.Pos.X, _template.User.GlobalRank.Pos.Y)
-                    },
-                    globalRankStr,
-                    Brushes.Solid(_template.User.GlobalRank.Color),
-                    rankPen
-                ));
-            }
-
-            if (_template.User.GuildRank.Show)
-            {
-                var guildRankStr = stats.GuildRanking.ToString();
-
-                var guildRankFont = GetTruncatedFont(
-                    _fonts.UniSans,
-                    _template.User.GuildRank.FontSize,
-                    FontStyle.Bold,
-                    guildRankStr,
-                    43);
-
-                img.Mutate(x => x.DrawText(
-                    new RichTextOptions(guildRankFont)
-                    {
-                        Origin = new(_template.User.GuildRank.Pos.X, _template.User.GuildRank.Pos.Y)
-                    },
-                    guildRankStr,
-                    Brushes.Solid(_template.User.GuildRank.Color),
-                    rankPen
-                ));
-            }
-
-            //avatar
-            if (_template.User.Icon.Show)
-            {
-                try
-                {
-                    var avatarUrl = stats.User.RealAvatarUrl();
-
-                    if (avatarUrl is not null)
-                    {
-                        var result = await _c.GetImageDataAsync(avatarUrl);
-                        if (!result.TryPickT0(out var data, out _))
+                        x.DrawText(new RichTextOptions(clubFont)
                         {
-                            using (var http = _httpFactory.CreateClient())
-                            {
-                                var avatarData = await http.GetByteArrayAsync(avatarUrl);
-                                using (var tempDraw = Image.Load<Rgba32>(avatarData))
-                                {
-                                    tempDraw.Mutate(x => x
-                                        .Resize(_template.User.Icon.Size.X, _template.User.Icon.Size.Y)
-                                        .ApplyRoundedCorners(Math.Max(_template.User.Icon.Size.X,
-                                                                 _template.User.Icon.Size.Y)
-                                                             / 2.0f));
-                                    await using (var stream = await tempDraw.ToStreamAsync())
-                                    {
-                                        data = stream.ToArray();
-                                    }
-                                }
-                            }
-
-                            await _c.SetImageDataAsync(avatarUrl, data);
-                        }
-
-                        using var toDraw = Image.Load(data);
-                        if (toDraw.Size != new Size(_template.User.Icon.Size.X, _template.User.Icon.Size.Y))
-                            toDraw.Mutate(x => x.Resize(_template.User.Icon.Size.X, _template.User.Icon.Size.Y));
-
-                        img.Mutate(x => x.DrawImage(toDraw,
-                            new Point(_template.User.Icon.Pos.X, _template.User.Icon.Pos.Y),
-                            1));
+                            HorizontalAlignment = HorizontalAlignment.Right,
+                            VerticalAlignment = VerticalAlignment.Top,
+                            FallbackFontFamilies = _fonts.FallBackFonts,
+                            Origin = new(_template.Club.Name.Pos.X + 50, _template.Club.Name.Pos.Y - 8)
+                        },
+                            clubName,
+                            Brushes.Solid(_template.Club.Name.Color),
+                            outlinePen);
                     }
-                }
-                catch (Exception ex)
-                {
-                    Log.Warning(ex, "Error drawing avatar image");
-                }
+
+                    Font GetTruncatedFont(
+                        FontFamily fontFamily,
+                        int fontSize,
+                        FontStyle style,
+                        string text,
+                        int maxSize)
+                    {
+                        var font = fontFamily.CreateFont(fontSize, style);
+                        var size = TextMeasurer.MeasureSize(text, new(font));
+                        var scale = maxSize / size.Width;
+                        if (scale < 1)
+                            font = fontFamily.CreateFont(fontSize * scale, style);
+
+                        return font;
+                    }
+
+
+                    if (_template.User.GlobalLevel.Show)
+                    {
+                        // up to 83 width
+
+                        var globalLevelFont = GetTruncatedFont(
+                            _fonts.NotoSans,
+                            _template.User.GlobalLevel.FontSize,
+                            FontStyle.Bold,
+                            stats.Global.Level.ToString(),
+                            75);
+
+
+                        x.DrawText(stats.Global.Level.ToString(),
+                            globalLevelFont,
+                            _template.User.GlobalLevel.Color,
+                            new(_template.User.GlobalLevel.Pos.X, _template.User.GlobalLevel.Pos.Y)); //level
+                    }
+
+                    if (_template.User.GuildLevel.Show)
+                    {
+                        var guildLevelFont = GetTruncatedFont(
+                            _fonts.NotoSans,
+                            _template.User.GuildLevel.FontSize,
+                            FontStyle.Bold,
+                            stats.Guild.Level.ToString(),
+                            75);
+
+
+                        x.DrawText(stats.Guild.Level.ToString(),
+                            guildLevelFont,
+                            _template.User.GuildLevel.Color,
+                            new(_template.User.GuildLevel.Pos.X, _template.User.GuildLevel.Pos.Y));
+                    }
+
+
+                    var global = stats.Global;
+                    var guild = stats.Guild;
+
+                    //xp bar
+                    if (_template.User.Xp.Bar.Show)
+                    {
+                        var xpPercent = global.LevelXp / (float)global.RequiredXp;
+                        DrawXpBar(xpPercent, _template.User.Xp.Bar.Global, img);
+                        xpPercent = guild.LevelXp / (float)guild.RequiredXp;
+                        DrawXpBar(xpPercent, _template.User.Xp.Bar.Guild, img);
+                    }
+
+                    if (_template.User.Xp.Global.Show)
+                    {
+                        x.DrawText(
+                            new RichTextOptions(_fonts.NotoSans.CreateFont(_template.User.Xp.Global.FontSize,
+                                FontStyle.Bold))
+                            {
+                                HorizontalAlignment = HorizontalAlignment.Center,
+                                VerticalAlignment = VerticalAlignment.Center,
+                                Origin = new(_template.User.Xp.Global.Pos.X, _template.User.Xp.Global.Pos.Y),
+                            },
+                            $"{global.LevelXp}/{global.RequiredXp}",
+                            Brushes.Solid(_template.User.Xp.Global.Color),
+                            outlinePen);
+                    }
+
+                    if (_template.User.Xp.Guild.Show)
+                    {
+                        x.DrawText(
+                            new RichTextOptions(_fonts.NotoSans.CreateFont(_template.User.Xp.Guild.FontSize,
+                                FontStyle.Bold))
+                            {
+                                HorizontalAlignment = HorizontalAlignment.Center,
+                                VerticalAlignment = VerticalAlignment.Center,
+                                Origin = new(_template.User.Xp.Guild.Pos.X, _template.User.Xp.Guild.Pos.Y)
+                            },
+                            $"{guild.LevelXp}/{guild.RequiredXp}",
+                            Brushes.Solid(_template.User.Xp.Guild.Color),
+                            outlinePen);
+                    }
+
+                    var rankPen = new SolidPen(Color.White, 1);
+                    //ranking
+                    if (_template.User.GlobalRank.Show)
+                    {
+                        var globalRankStr = stats.GlobalRanking.ToString();
+
+                        var globalRankFont = GetTruncatedFont(
+                            _fonts.UniSans,
+                            _template.User.GlobalRank.FontSize,
+                            FontStyle.Bold,
+                            globalRankStr,
+                            68);
+
+                        x.DrawText(
+                            new RichTextOptions(globalRankFont)
+                            {
+                                Origin = new(_template.User.GlobalRank.Pos.X, _template.User.GlobalRank.Pos.Y)
+                            },
+                            globalRankStr,
+                            Brushes.Solid(_template.User.GlobalRank.Color),
+                            rankPen
+                        );
+                    }
+
+                    if (_template.User.GuildRank.Show)
+                    {
+                        var guildRankStr = stats.GuildRanking.ToString();
+
+                        var guildRankFont = GetTruncatedFont(
+                            _fonts.UniSans,
+                            _template.User.GuildRank.FontSize,
+                            FontStyle.Bold,
+                            guildRankStr,
+                            43);
+
+                        x.DrawText(
+                            new RichTextOptions(guildRankFont)
+                            {
+                                Origin = new(_template.User.GuildRank.Pos.X, _template.User.GuildRank.Pos.Y)
+                            },
+                            guildRankStr,
+                            Brushes.Solid(_template.User.GuildRank.Color),
+                            rankPen
+                        );
+                    }
+
+                    if (_template.User.Icon.Show)
+                    {
+                        try
+                        {
+                            using var toDraw = Image.Load(avatarImageData);
+                            if (toDraw.Size != new Size(_template.User.Icon.Size.X, _template.User.Icon.Size.Y))
+                                toDraw.Mutate(x
+                                    => x.Resize(_template.User.Icon.Size.X, _template.User.Icon.Size.Y));
+
+                            x.DrawImage(toDraw,
+                                new Point(_template.User.Icon.Pos.X, _template.User.Icon.Pos.Y),
+                                1);
+                        }
+                        catch (Exception ex)
+                        {
+                            Log.Warning(ex, "Error drawing avatar image");
+                        }
+                    }
+                });
             }
 
             //club image
             if (_template.Club.Icon.Show)
                 await DrawClubImage(img, stats);
 
-            // #if GLOBAL_ELLIE
             await DrawFrame(img, stats.User.UserId);
-            // #endif
 
             var outputSize = _template.OutputSize;
             if (outputSize.X != img.Width || outputSize.Y != img.Height)
@@ -1122,7 +1041,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         return await _images.GetXpBackgroundImageAsync();
     }
 
-    // #if GLOBAL_ELLIE
     private async Task DrawFrame(Image<Rgba32> img, ulong userId)
     {
         var patron = await _ps.GetPatronAsync(userId);
@@ -1150,7 +1068,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         if (frame is not null)
             img.Mutate(x => x.DrawImage(frame, new Point(0, 0), new GraphicsOptions()));
     }
-    // #endif
 
     private void DrawXpBar(float percent, XpBar info, Image<Rgba32> img)
     {
@@ -1164,34 +1081,18 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
 
         float x3, x4, y3, y4;
 
-        if (info.Direction == XpTemplateDirection.Down)
+        var matrix = info.Direction switch
         {
-            x3 = x1;
-            x4 = x2;
-            y3 = y1 + length;
-            y4 = y2 + length;
-        }
-        else if (info.Direction == XpTemplateDirection.Up)
-        {
-            x3 = x1;
-            x4 = x2;
-            y3 = y1 - length;
-            y4 = y2 - length;
-        }
-        else if (info.Direction == XpTemplateDirection.Left)
-        {
-            x3 = x1 - length;
-            x4 = x2 - length;
-            y3 = y1;
-            y4 = y2;
-        }
-        else
-        {
-            x3 = x1 + length;
-            x4 = x2 + length;
-            y3 = y1;
-            y4 = y2;
-        }
+            XpTemplateDirection.Down => new float[,] { { 0, 1 }, { 0, 1 } },
+            XpTemplateDirection.Up => new float[,] { { 0, -1 }, { 0, -1 } },
+            XpTemplateDirection.Left => new float[,] { { -1, 0 }, { -1, 0 } },
+            _ => new float[,] { { 1, 0 }, { 1, 0 } },
+        };
+
+        x3 = x1 + matrix[0, 0] * length;
+        x4 = x2 + matrix[1, 0] * length;
+        y3 = y1 + matrix[0, 1] * length;
+        y4 = y2 + matrix[1, 1] * length;
 
         img.Mutate(x => x.FillPolygon(info.Color,
             new PointF(x1, y1),
@@ -1278,7 +1179,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         if (!data.Shop.IsEnabled)
             return new(default(Dictionary<string, XpConfig.ShopItemInfo>));
 
-        return new(_xpConfig.Data.Shop.Bgs?.Where(x => x.Value.Price >= 0).ToDictionary(x => x.Key, x => x.Value));
+        return new(_xpConfig.Data.Shop.Bgs?.Where(x => x.Value.Price >= 0)
+            .ToDictionary(x => x.Key, x => x.Value));
     }
 
     public ValueTask<Dictionary<string, XpConfig.ShopItemInfo>?> GetShopFrames()
@@ -1287,7 +1189,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
         if (!data.Shop.IsEnabled)
             return new(default(Dictionary<string, XpConfig.ShopItemInfo>));
 
-        return new(_xpConfig.Data.Shop.Frames?.Where(x => x.Value.Price >= 0).ToDictionary(x => x.Key, x => x.Value));
+        return new(_xpConfig.Data.Shop.Frames?.Where(x => x.Value.Price >= 0)
+            .ToDictionary(x => x.Key, x => x.Value));
     }
 
     public async Task<BuyResult> BuyShopItemAsync(ulong userId, XpShopItemType type, string key)
@@ -1321,7 +1224,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
             if (item is null || item.Price < 0)
                 return BuyResult.UnknownItem;
 
-            if (item.Price > 0 && !await _cs.RemoveAsync(userId, item.Price, new("xpshop", "buy", $"Background {key}")))
+            if (item.Price > 0 &&
+                !await _cs.RemoveAsync(userId, item.Price, new("xpshop", "buy", $"Background {key}")))
                 return BuyResult.InsufficientFunds;
 
 
@@ -1445,7 +1349,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
     public bool IsShopEnabled()
         => _xpConfig.Data.Shop.IsEnabled;
 
-    public async Task<int> GetTotalGuildUsers(ulong requestGuildId, List<ulong>? guildUsers = null)
+    public async Task<int> GetGuildXpUsersCountAsync(ulong requestGuildId, List<ulong>? guildUsers = null)
     {
         await using var ctx = _db.GetDbContext();
         return await ctx.GetTable<UserXpStats>()
@@ -1478,12 +1382,64 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand
     }
 }
 
-public sealed class UserXpBatch
+public sealed class XpTemplateService : IEService, IReadyExecutor
 {
-    [Key]
-    public ulong UserId { get; set; }
+    private const string XP_TEMPLATE_PATH = "./data/xp_template.json";
 
-    public ulong GuildId { get; set; }
-    public string Username { get; set; } = string.Empty;
-    public string AvatarId { get; set; } = string.Empty;
+    private readonly IPubSub _pubSub;
+    private XpTemplate _template = new();
+    private readonly TypedKey<bool> _xpTemplateReloadKey = new("xp.template.reload");
+
+    public XpTemplateService(IPubSub pubSub)
+    {
+        _pubSub = pubSub;
+    }
+
+    private void InternalReloadXpTemplate()
+    {
+        try
+        {
+            var settings = new JsonSerializerSettings
+            {
+                ContractResolver = new RequireObjectPropertiesContractResolver()
+            };
+
+            if (!File.Exists(XP_TEMPLATE_PATH))
+            {
+                var newTemp = new XpTemplate();
+                newTemp.Version = 2;
+                File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(newTemp, Formatting.Indented));
+            }
+
+            _template = JsonConvert.DeserializeObject<XpTemplate>(
+                File.ReadAllText(XP_TEMPLATE_PATH),
+                settings)!;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "xp_template.json is invalid. Loaded default values");
+            _template = new();
+        }
+    }
+
+    public void ReloadXpTemplate()
+        => _pubSub.Pub(_xpTemplateReloadKey, true);
+
+    public async Task OnReadyAsync()
+    {
+        InternalReloadXpTemplate();
+        await _pubSub.Sub(_xpTemplateReloadKey,
+            _ =>
+            {
+                InternalReloadXpTemplate();
+                return default;
+            });
+    }
+
+    public XpTemplate GetTemplate()
+        => _template;
+}
+
+public sealed class ReadyXpTempalte(XpTemplate template)
+{
 }
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Xp/_common/FullUserStats.cs b/src/EllieBot/Modules/Xp/common/FullUserStats.cs
similarity index 100%
rename from src/EllieBot/Modules/Xp/_common/FullUserStats.cs
rename to src/EllieBot/Modules/Xp/common/FullUserStats.cs
diff --git a/src/EllieBot/Modules/Xp/_common/IXpCleanupService.cs b/src/EllieBot/Modules/Xp/common/IXpCleanupService.cs
similarity index 100%
rename from src/EllieBot/Modules/Xp/_common/IXpCleanupService.cs
rename to src/EllieBot/Modules/Xp/common/IXpCleanupService.cs
diff --git a/src/EllieBot/Modules/Xp/_common/UserCacheItem.cs b/src/EllieBot/Modules/Xp/common/UserCacheItem.cs
similarity index 100%
rename from src/EllieBot/Modules/Xp/_common/UserCacheItem.cs
rename to src/EllieBot/Modules/Xp/common/UserCacheItem.cs
diff --git a/src/EllieBot/Modules/Xp/_common/XpCleanupService.cs b/src/EllieBot/Modules/Xp/common/XpCleanupService.cs
similarity index 100%
rename from src/EllieBot/Modules/Xp/_common/XpCleanupService.cs
rename to src/EllieBot/Modules/Xp/common/XpCleanupService.cs
diff --git a/src/EllieBot/Modules/Xp/_common/XpTemplate.cs b/src/EllieBot/Modules/Xp/common/XpTemplate.cs
similarity index 99%
rename from src/EllieBot/Modules/Xp/_common/XpTemplate.cs
rename to src/EllieBot/Modules/Xp/common/XpTemplate.cs
index 020ad7f..077393f 100644
--- a/src/EllieBot/Modules/Xp/_common/XpTemplate.cs
+++ b/src/EllieBot/Modules/Xp/common/XpTemplate.cs
@@ -7,8 +7,8 @@ namespace EllieBot.Modules.Xp;
 
 public class XpTemplate
 {
-    public int Version { get; set; } = 0;
-    
+    public int Version { get; set; } = 2;
+
     [JsonProperty("output_size")]
     public XpTemplatePos OutputSize { get; set; } = new()
     {
diff --git a/src/EllieBot/Services/GrpcApi/XpSvc.cs b/src/EllieBot/Services/GrpcApi/XpSvc.cs
index c403340..3cf9167 100644
--- a/src/EllieBot/Services/GrpcApi/XpSvc.cs
+++ b/src/EllieBot/Services/GrpcApi/XpSvc.cs
@@ -217,7 +217,7 @@ public class XpSvc : GrpcXp.GrpcXpBase, IGrpcSvc, IEService
             throw new RpcException(new Status(StatusCode.NotFound, "Guild not found"));
 
         var data = await _xp.GetGuildUserXps(request.GuildId, request.Page - 1);
-        var total = await _xp.GetTotalGuildUsers(request.GuildId);
+        var total = await _xp.GetGuildXpUsersCountAsync(request.GuildId);
 
         var reply = new GetXpLbReply
         {
diff --git a/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs
index 2368ac2..f80a86b 100644
--- a/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs
+++ b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs
@@ -9,12 +9,12 @@ namespace Ellie.Common;
 public sealed class MemoryBotCache : IBotCache
 {
     // needed for overwrites and Delete return value
-    private readonly object _cacheLock = new object();
+    private readonly ConcurrentDictionary<string, object> _locks = new();
     private readonly MemoryCache _cache;
 
     public MemoryBotCache()
     {
-        _cache = new MemoryCache(new MemoryCacheOptions());
+        _cache = new(new MemoryCacheOptions());
     }
 
     public ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true)
@@ -26,12 +26,14 @@ public sealed class MemoryBotCache : IBotCache
             item.AbsoluteExpirationRelativeToNow = expiry;
             return new(true);
         }
-        
-        lock (_cacheLock)
+
+        var cacheLock = _locks.GetOrAdd(key.Key, static _ => new());
+
+        lock (cacheLock)
         {
             if (_cache.TryGetValue(key.Key, out var old) && old is not null)
                 return new(false);
-            
+
             using var item = _cache.CreateEntry(key.Key);
             item.Value = value;
             item.AbsoluteExpirationRelativeToNow = expiry;
@@ -61,9 +63,10 @@ public sealed class MemoryBotCache : IBotCache
 
     public ValueTask<bool> RemoveAsync<T>(TypedKey<T> key)
     {
-        lock (_cacheLock)
+        var cacheLock = _locks.GetOrAdd(key.Key, static _ => new());
+        lock (cacheLock)
         {
-            var toReturn = _cache.TryGetValue(key.Key, out var old ) && old is not null;
+            var toReturn = _cache.TryGetValue(key.Key, out var old) && old is not null;
             _cache.Remove(key.Key);
             return new(toReturn);
         }
diff --git a/src/EllieBot/data/xp.yml b/src/EllieBot/data/xp.yml
index e32fe62..8188f2c 100644
--- a/src/EllieBot/data/xp.yml
+++ b/src/EllieBot/data/xp.yml
@@ -1,17 +1,13 @@
 # DO NOT CHANGE
-version: 7
+version: 10
 # How much XP will the users receive per message
-xpPerMessage: 3
+textXpPerMessage: 3
 # How often can the users receive XP, in seconds
-messageXpCooldown: 300
+textXpCooldown: 300
 # Amount of xp users gain from posting an image
-xpFromImage: 0
+textXpFromImage: 3
 # Average amount of xp earned per minute in VC
 voiceXpPerMinute: 0
-# The maximum amount of minutes the bot will keep track of a user in a voice channel
-voiceMaxMinutes: 720
-# The amount of currency users will receive for each point of global xp that they earn
-currencyPerXp: 0
 # Xp Shop config
 shop:
   # Whether the xp shop is enabled
diff --git a/src/EllieBot/data/xp_template.json b/src/EllieBot/data/xp_template.json
index df7f289..5ebe91b 100644
--- a/src/EllieBot/data/xp_template.json
+++ b/src/EllieBot/data/xp_template.json
@@ -1,5 +1,5 @@
 {
-  "Version": 1,
+  "Version": 2,
   "output_size": {
     "X": 800,
     "Y": 392