🧩 Day 16 of #30DaysOfSolidity β€” Building a Modular Profile System for Web3 Games with `delegatecall`



This content originally appeared on DEV Community and was authored by Saurav Kumar

🎯 Introduction

In modern Web3 development, extensibility and modularity are essential for scalable dApps and on-chain games. Imagine a Web3 game where players have profiles, and can install plugins like Achievements, Inventory, or Battle Stats β€” without ever redeploying the core contract.

That’s what we’ll build today β€” a modular player profile system powered by delegatecall, where:

  • The core contract stores player data.
  • Plugins are separate contracts that extend functionality.
  • The core uses delegatecall to execute plugin logic inside its own storage context.

This real-world pattern is inspired by Lens Protocol, Decentraland, and The Sandbox, where modular smart contract architecture enables long-term upgradability and community-driven innovation.

πŸ“ Project File Structure

Here’s how your project will look inside the day16-modular-profile directory:

day16-modular-profile/
β”œβ”€β”€ foundry.toml
β”œβ”€β”€ lib/
β”‚   └── forge-std/                 # Foundry standard library
β”œβ”€β”€ script/
β”‚   └── Deploy.s.sol               # Deployment script
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ CoreProfile.sol            # Main contract
β”‚   β”œβ”€β”€ IPlugin.sol                # Plugin interface
β”‚   └── plugins/
β”‚       └── AchievementPlugin.sol  # Example plugin contract
β”œβ”€β”€ test/
β”‚   └── CoreProfile.t.sol          # (Optional) Foundry tests
└── frontend/                      # React + Ethers.js frontend (optional)
    β”œβ”€β”€ package.json
    β”œβ”€β”€ src/
    β”‚   └── App.jsx
    └── public/
        └── index.html

This mirrors real-world monorepo structures β€” smart contracts managed with Foundry, and a React frontend for interaction.

βš™ Setup β€” Foundry Project

# Create new foundry project
forge init day16-modular-profile

cd day16-modular-profile

# Create plugin and script folders
mkdir -p src/plugins script

πŸ’‘ Full Source Code with Running State

1⃣ src/IPlugin.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IPlugin {
    function execute(bytes calldata data) external;
}

2⃣ src/CoreProfile.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./IPlugin.sol";

contract CoreProfile {
    struct Profile {
        string name;
        string avatar;
        mapping(address => bool) activePlugins;
    }

    mapping(address => Profile) private profiles;
    mapping(string => address) public pluginRegistry;

    event ProfileCreated(address indexed user, string name, string avatar);
    event PluginRegistered(string name, address plugin);
    event PluginActivated(address indexed user, string plugin);
    event PluginExecuted(address indexed user, string plugin, bytes data);

    modifier onlyRegisteredPlugin(string memory _plugin) {
        require(pluginRegistry[_plugin] != address(0), "Plugin not registered");
        _;
    }

    /// @notice Create player profile
    function createProfile(string calldata _name, string calldata _avatar) external {
        Profile storage p = profiles[msg.sender];
        p.name = _name;
        p.avatar = _avatar;
        emit ProfileCreated(msg.sender, _name, _avatar);
    }

    /// @notice Register new plugin
    function registerPlugin(string calldata _name, address _plugin) external {
        pluginRegistry[_name] = _plugin;
        emit PluginRegistered(_name, _plugin);
    }

    /// @notice Activate plugin for user
    function activatePlugin(string calldata _pluginName) external onlyRegisteredPlugin(_pluginName) {
        Profile storage p = profiles[msg.sender];
        p.activePlugins[pluginRegistry[_pluginName]] = true;
        emit PluginActivated(msg.sender, _pluginName);
    }

    /// @notice Execute plugin logic via delegatecall
    function executePlugin(string calldata _pluginName, bytes calldata data)
        external
        onlyRegisteredPlugin(_pluginName)
    {
        address pluginAddr = pluginRegistry[_pluginName];
        Profile storage p = profiles[msg.sender];
        require(p.activePlugins[pluginAddr], "Plugin not active");

        (bool success, ) = pluginAddr.delegatecall(
            abi.encodeWithSelector(IPlugin.execute.selector, data)
        );
        require(success, "Delegatecall failed");

        emit PluginExecuted(msg.sender, _pluginName, data);
    }

    /// @notice Get player profile info
    function getProfile(address _user) external view returns (string memory, string memory) {
        Profile storage p = profiles[_user];
        return (p.name, p.avatar);
    }
}

3⃣ src/plugins/AchievementPlugin.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "../IPlugin.sol";

contract AchievementPlugin is IPlugin {
    mapping(address => uint256) public achievements;

    event AchievementUnlocked(address indexed player, string achievement);

    function execute(bytes calldata data) external override {
        (string memory achievement) = abi.decode(data, (string));
        achievements[msg.sender] += 1;
        emit AchievementUnlocked(msg.sender, achievement);
    }
}

4⃣ script/Deploy.s.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Script.sol";
import "../src/CoreProfile.sol";
import "../src/plugins/AchievementPlugin.sol";

contract Deploy is Script {
    function run() external {
        vm.startBroadcast();

        CoreProfile core = new CoreProfile();
        AchievementPlugin plugin = new AchievementPlugin();

        core.registerPlugin("Achievements", address(plugin));

        vm.stopBroadcast();
    }
}

πŸ§ͺ Run & Test the Project

# Compile contracts
forge build

# Run deployment
forge script script/Deploy.s.sol --rpc-url <YOUR_RPC_URL> --private-key <PRIVATE_KEY> --broadcast

πŸ”’ Safe delegatecall Practices

Since delegatecall executes plugin code in the caller’s storage, safety is critical.

βœ… Follow these best practices:

  • Maintain consistent storage layout between core and plugin contracts.
  • Use a verified registry for plugin addresses.
  • Validate function selectors and return data.
  • Never let users input arbitrary contract addresses.

This design pattern resembles:

  • EIP-2535 (Diamond Standard)
  • Proxy + Logic split pattern (UUPS / Transparent Proxy)
  • Modular NFT frameworks like those used by Lens and Sandbox.

🌐 React + Ethers.js Frontend Example

Add this file under:
frontend/src/App.jsx

import { useState } from "react";
import { ethers } from "ethers";
import CoreProfileAbi from "./CoreProfile.json";

const CORE_PROFILE = "0xYourDeployedAddress";

function App() {
  const [name, setName] = useState("");
  const [avatar, setAvatar] = useState("");
  const [achievement, setAchievement] = useState("");

  async function getContract() {
    const provider = new ethers.BrowserProvider(window.ethereum);
    const signer = await provider.getSigner();
    return new ethers.Contract(CORE_PROFILE, CoreProfileAbi, signer);
  }

  async function createProfile() {
    const contract = await getContract();
    await contract.createProfile(name, avatar);
    alert("Profile Created!");
  }

  async function activatePlugin() {
    const contract = await getContract();
    await contract.activatePlugin("Achievements");
    alert("Plugin Activated!");
  }

  async function unlockAchievement() {
    const contract = await getContract();
    const data = ethers.AbiCoder.defaultAbiCoder().encode(["string"], [achievement]);
    await contract.executePlugin("Achievements", data);
    alert("Achievement Unlocked!");
  }

  return (
    <div className="p-8 max-w-lg mx-auto text-center">
      <h1 className="text-2xl font-bold mb-4">Web3 Modular Profile</h1>

      <input placeholder="Name" onChange={e => setName(e.target.value)} className="border p-2 mb-2" />
      <input placeholder="Avatar URL" onChange={e => setAvatar(e.target.value)} className="border p-2 mb-2" />
      <button onClick={createProfile} className="bg-blue-500 text-white px-4 py-2 rounded">Create Profile</button>

      <hr className="my-4" />
      <button onClick={activatePlugin} className="bg-green-500 text-white px-4 py-2 rounded">Activate Plugin</button>

      <hr className="my-4" />
      <input placeholder="Achievement" onChange={e => setAchievement(e.target.value)} className="border p-2 mb-2" />
      <button onClick={unlockAchievement} className="bg-purple-500 text-white px-4 py-2 rounded">Unlock</button>
    </div>
  );
}

export default App;

πŸš€ Real-World Applications

This modular architecture directly maps to real industry use cases:

  • GameFi Platforms β†’ Plugin-based avatars and stats (e.g., TreasureDAO)
  • Social Protocols β†’ Extensible profile features (e.g., Lens Protocol)
  • DAO Tools β†’ Modular role or reward extensions
  • Metaverse Worlds β†’ Upgradable land or character logic

🧠 Key Takeaways

  • delegatecall lets plugins run logic inside the caller’s storage.
  • Modular systems enable feature expansion without redeploying.
  • Always validate plugins to avoid malicious injections.
  • Industry-standard approach for scalable Web3 design.

πŸ”— Follow Me


This content originally appeared on DEV Community and was authored by Saurav Kumar