Building Reactive Smart Contracts on Somnia
A Developer Guide with Real Solidity Examples
1. Introduction
As Web3 developers, we often build systems that require automatic reactions.
Examples from real projects like onchain games, PotWar pools, prediction markets:
- Last player wins when timer ends
- Auto reward after chest open
- Pool auto payout
- NFT level up after XP
- DAO proposal auto execute
With traditional smart contracts, this requires:
• Backend server
• Event indexer
• Cron jobs
• Extra user transactions
This increases complexity, cost, and centralization.
Somnia On-Chain Reactivity solves this by allowing smart contracts to automatically react to events fully on-chain.
2. What is Somnia On-Chain Reactivity?
Somnia lets smart contracts execute logic automatically when subscribed events occur.
Traditional Flow :
User Action → Event → Backend Listener → New Tx → State Update
Somnia Reactive Flow :
User Action → Event → Validator Detect → _onEvent() → State Update
No backend.
No extra user gas.
Fully decentralized.
3. Why This Matters for Web3 Devs
For builders working on gaming, DeFi, or automation:
- No server infra
- Less latency
- Cleaner architecture
- Better UX
- Faster hackathon builds
For example, in Last Player Standing, we don’t need a backend timer contract logic can react automatically.
4. Example: Magic Chest Reactive Game
Let’s build a small game.
Game Rules
- Common chest → +10 coins
- Rare chest → +50 coins
- Legendary chest → NFT sword
Player opens chest → reward auto given.
5. Solidity Contract Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import { SomniaEventHandler }
from "@somnia-chain/reactivity-contracts/contracts/SomniaEventHandler.sol";
contract MagicChestReactiveGame is SomniaEventHandler {
event ChestOpened(address indexed player, uint256 chestType);
mapping(address => uint256) public coins;
mapping(address => bool) public hasSword;
bytes32 constant CHEST_SIG =
keccak256("ChestOpened(address,uint256)");
uint256 constant COMMON = 1;
uint256 constant RARE = 2;
uint256 constant LEGENDARY = 3;
function openChest(uint256 chestType) external {
emit ChestOpened(msg.sender, chestType);
}
function _onEvent(
address,
bytes32[] calldata topics,
bytes calldata data
) internal override {
require(topics[0] == CHEST_SIG, "Wrong event");
address player =
address(uint160(uint256(topics[1])));
uint256 chestType =
abi.decode(data, (uint256));
if (chestType == COMMON)
coins[player] += 10;
else if (chestType == RARE)
coins[player] += 50;
else if (chestType == LEGENDARY)
hasSword[player] = true;
}
}
6. How Reactivity Works Internally
- Player calls openChest()
- Event emitted
- Somnia validators detect subscription
- _onEvent() executes automatically
- State updated
- No extra transaction required.
Reactive tx is executed by validator address:
0x0000000000000000000000000000000000000100
7. Creating a Subscription for the event :
import { SDK } from "@somnia-chain/reactivity";
import { privateKeyToAccount } from "viem/accounts";
import { createPublicClient, createWalletClient, http } from "viem";
import { somniaTestnet } from "viem/chains";
import { keccak256, toBytes } from "viem";
const CONTRACT = "0xYourContractAddress";
async function main() {
const account = privateKeyToAccount(
process.env.PRIVATE_KEY as `0x${string}`
);
const publicClient = createPublicClient({
chain: somniaTestnet,
transport: http(),
});
const walletClient = createWalletClient({
account,
chain: somniaTestnet,
transport: http(),
});
const sdk = new SDK({
public: publicClient,
wallet: walletClient
});
const EVENT_SIG = keccak256(
toBytes("ChestOpened(address,uint256)")
);
const txHash = await sdk.createSoliditySubscription({
handlerContractAddress: CONTRACT,
emitter: CONTRACT,
eventTopics: [EVENT_SIG],
gasLimit: 3_000_000n,
});
const receipt = await publicClient.waitForTransactionReceipt({
hash: txHash,
});
// 👉 Extract subscriptionId
const log = receipt.logs[0];
const subscriptionId = BigInt(log.topics[2]);
console.log("Subscription ID:", subscriptionId.toString());
}
main();
Important Notes
- Need 32 STT balance
- Save subscription ID
- Event signature must match exactly
Now after subscribing event that we have to trigger from reactivity we need to test this ,
Testing Reactivity
After subscription, test contract.
import { ethers } from "hardhat";
const CONTRACT = "0xYourContractAddress";
async function main() {
const [user] = await ethers.getSigners();
const game = await ethers.getContractAt(
"MagicChestReactiveGame",
CONTRACT
);
const before = await game.coins(user.address);
console.log("Coins before:", before.toString());
const tx = await game.openChest(1);
await tx.wait();
console.log("Waiting for Somnia reactivity...");
await new Promise(r => setTimeout(r,15000));
const after = await game.coins(user.address);
console.log("Coins after:", after.toString());
if(after > before)
console.log("✅ Reactivity Working");
else
console.log("❌ No Reactivity");
}
main();
Real Use Cases
-
Gaming
- Last Player Standing
- PotWar auto payout
- Loot rewards
- NFT upgrades
-
Prediction Markets
- Prediction auto reward
- Score updates
-
DeFi
- Liquidations
- Yield distribution
-
DAO
- Proposal auto execute
Conclusion
Somnia On-Chain Reactivity removes backend dependency and enables fully autonomous smart contracts.
For developers building automated gaming logic, prediction markets, or DeFi automation, this unlocks a new level of scalability and simplicity.
The future of Web3 apps is reactive.
Neeraj Choubisa (Nikku.Dev) is a Full-Stack Blockchain Engineer specializing in smart contract development, Web3 integrations, and consumer-focused decentralized applications.
Top comments (1)
Really solid walkthrough, Neeraj. The reactive pattern where validators detect and auto-execute _onEvent() is a game-changer for on-chain gaming — eliminates the whole backend cron + indexer stack that makes most dapp games fragile.
The "Last Player Standing" use case especially resonates. We built something similar with cryptoshot.space (Ethereum jackpot game) and the biggest pain point was exactly what you describe: coordinating off-chain timers with on-chain state. Having that reactivity native to the chain would simplify the architecture massively.
Curious about gas costs though — when the validator executes _onEvent(), who pays? Is it subsidized by the subscription fee (the 32 STT), or does the contract need a balance?