Skip to content
RustPluginGenerator

OnProjectileRicochet

OnProjectileRicochet is called when a client reports that one of its fired projectiles ricocheted off a surface. Returning a non-null value cancels the server-side ricochet counter update for that projectile.

Signature
object OnProjectileRicochet(BasePlayer player, ProtoBuf.PlayerProjectileRicochet playerProjectileRicochet)

Parameters

Hook parameters
ParameterTypeDescription
playerBasePlayerThe BasePlayer who fired the projectile that ricocheted. Always a player entity, so you can use it directly without an Initiator lookup.
playerProjectileRicochetProtoBuf.PlayerProjectileRicochetThe ProtoBuf.PlayerProjectileRicochet message sent by the client, carrying the projectileID of the projectile reporting a bounce. Use projectileID to correlate with tracked projectiles.

Return value

Returning null lets the original code run: the projectile's ricochet counter is incremented and the tracker entry is rewritten. Returning any non-null value (for example true) skips that block entirely, so the counter and dictionary entry stay as they were.

How it works

The hook fires inside BasePlayer.OnProjectileRicochet, the server handler for the client-sent projectile ricochet message. It runs after the anti-cheat sanity checks on the message (projectile ownership, fired-time window) pass, and just before the server increments the ricochet count stored in its fired-projectile tracker.

The player argument is the shooter, so you can inspect permissions, team, held item or distance without resolving an initiator. playerProjectileRicochet.projectileID identifies the projectile.

There is only one injection site and no narrowing beyond BasePlayer is useful, since the game passes the shooting player. The hook is client-driven: it fires when the client says a projectile bounced, so treat it as a data signal, not as proof of a physical bounce.

Because the hook fires per ricochet message, keep the body cheap. A returned non-null value makes the original branch skip updating ricochets and the fired-projectiles dictionary entry for that projectile, which affects the server's tracking for later anti-cheat checks on the same projectile.

Example plugin: Track ricochets per player and reward trick shots

RicochetTracker.cs
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("Ricochet Tracker", "RustPluginGenerator", "1.0.0")]
    [Description("Counts projectile ricochets per player and rewards chained bounces")]
    public class RicochetTracker : RustPlugin
    {
        private class PluginConfig
        {
            [JsonProperty("Bounces required for reward")]
            public int BouncesRequired = 2;

            [JsonProperty("Reward item shortname")]
            public string RewardShortname = "scrap";

            [JsonProperty("Reward amount")]
            public int RewardAmount = 25;

            [JsonProperty("Seconds before bounce data expires")]
            public float ExpirySeconds = 10f;
        }

        private class BounceData
        {
            public ulong PlayerId;
            public int Count;
            public float LastSeen;
        }

        private PluginConfig config;
        private readonly Dictionary<ulong, BounceData> bounces = new Dictionary<ulong, BounceData>();
        private Timer cleanupTimer;

        protected override void LoadDefaultConfig() => config = new PluginConfig();

        protected override void LoadConfig()
        {
            base.LoadConfig();
            try
            {
                config = Config.ReadObject<PluginConfig>();
                if (config == null) throw new Exception();
            }
            catch
            {
                PrintWarning("Configuration file is invalid, using defaults");
                LoadDefaultConfig();
            }
            SaveConfig();
        }

        protected override void SaveConfig() => Config.WriteObject(config);

        protected override void LoadDefaultMessages()
        {
            lang.RegisterMessages(new Dictionary<string, string>
            {
                ["RicochetChain"] = "Nice shot, {0}: {1} ricochets on one projectile!",
                ["RewardReceived"] = "You received {0} x {1} for the ricochet chain."
            }, this);
        }

        private string Msg(string key, string userId = null, params object[] args) =>
            string.Format(lang.GetMessage(key, this, userId), args);

        private void OnServerInitialized()
        {
            cleanupTimer = timer.Every(5f, CleanupExpired);
        }

        private object OnProjectileRicochet(BasePlayer player, ProtoBuf.PlayerProjectileRicochet playerProjectileRicochet)
        {
            if (player == null || playerProjectileRicochet == null) return null;
            if (!player.IsConnected || player.IsNpc) return null;

            ulong id = (ulong)playerProjectileRicochet.projectileID;
            BounceData data;
            if (!bounces.TryGetValue(id, out data))
            {
                data = new BounceData { PlayerId = player.userID.Get() };
                bounces[id] = data;
            }

            data.Count++;
            data.LastSeen = Time.realtimeSinceStartup;

            if (data.Count >= config.BouncesRequired)
            {
                bounces.Remove(id);
                player.ChatMessage(Msg("RicochetChain", player.UserIDString, data.Count, player.displayName));
                GiveReward(player);
            }

            return null;
        }

        private void GiveReward(BasePlayer player)
        {
            var item = ItemManager.CreateByName(config.RewardShortname, config.RewardAmount);
            if (item == null) return;

            player.GiveItem(item);
            player.ChatMessage(Msg("RewardReceived", player.UserIDString, config.RewardAmount, item.info.displayName.english));
        }

        private void CleanupExpired()
        {
            if (bounces.Count == 0) return;

            var now = Time.realtimeSinceStartup;
            var expired = new List<ulong>();
            foreach (var pair in bounces)
            {
                if (now - pair.Value.LastSeen > config.ExpirySeconds)
                    expired.Add(pair.Key);
            }

            foreach (var id in expired)
                bounces.Remove(id);
        }

        private void Unload()
        {
            if (cleanupTimer != null)
            {
                cleanupTimer.Destroy();
                cleanupTimer = null;
            }
            bounces.Clear();
        }
    }
}

The plugin counts ricochets per projectile and gives the player a chat message plus a configurable reward when a single projectile bounces a configurable number of times. OnProjectileRicochet keys each projectile by projectileID in a Dictionary<ulong, BounceData>, incrementing Count and updating LastSeen. A timer running every 5 seconds clears entries whose last bounce is older than ExpirySeconds so the dictionary cannot grow without bound. When the count reaches BouncesRequired the player gets the RicochetChain message and an item from config, and the entry is removed. Unload destroys the timer and clears the dictionary. The hook always returns null so default server tracking is untouched.

Common use cases

  • Reward trick shots

    Count bounces per projectile and hand out a small item or chat announcement when a player chains enough ricochets, encouraging skill-based play on PvP servers.

  • Feeds and stats

    Aggregate ricochet counts per player or weapon into periodic statistics, useful for leaderboards or event scoring without touching the server's own tracker.

  • Detect suspicious volume

    Track how many ricochet messages a player sends per minute and flag accounts that report implausible numbers, then pass the case to your staff tooling.

Pitfalls

  • The client controls the message

    The hook fires because a client sent a ricochet message, not because the server observed a bounce. Do not treat it as authoritative physics and never use it alone to ban players.

  • Returning a value skips server tracking

    Any non-null return prevents the ricochet counter and fired-projectile entry from being updated, which can desync later anti-cheat checks on that projectile. Return null unless you have a specific reason.

  • Per-projectile state must be cleaned up

    The hook can fire many times per second on a busy server. Bound your dictionaries with a timer or timestamp expiry, otherwise memory grows for projectiles that never report again.

Build a plugin with OnProjectileRicochet

Describe what should happen and get a compiled plugin that uses this hook.

Prompt: Reward trick shots using the OnProjectileRicochet hook, with a config file and a permission
Generate this plugin

Edit the prompt on the next screen. 2 free plugins a month, no card.

Last updated Oct 26, 2026. Found a mistake? Email support@rustplugingenerator.com.

Build a plugin with OnProjectileRicochet

Generate