NeonStrike
Real-time browser arena shooter with a server-authoritative 66 TPS game loop, client-side prediction and reconciliation, bot AI, team mechanics, and optional on-chain match staking with signed result verification on Base Sepolia.
TL;DR
NeonStrike keeps real-time simulation entirely off-chain and puts only stake escrow and verifiable outcomes on-chain. The game runs at ~66 ticks/sec server-side with client prediction/reconciliation; the Web3 layer is strictly additive - staked matches and persistent stats work without impacting game-loop latency.
- Server: Node.js + Express + Socket.IO, authoritative state, 15ms game loop
- Client: Vanilla JS + Canvas, Socket.IO, client-side prediction with sequence-number reconciliation, ethers.js
- Contracts:
GameManager.sol(staking + ECDSA result verification),LeaderboardRegistry.sol(persistent stats/achievements),GameToken.sol(ERC-20 rewards) - Network: Base Sepolia testnet
Role & Scope
- Role: Solo developer
- Stack: JavaScript (vanilla), Node.js, Socket.IO, Solidity, Hardhat, ethers.js
- Contracts deployed: Base Sepolia
Problem
The core design question: what is the minimum on-chain surface area that still delivers meaningful ownership and verifiability without poisoning game feel with transaction latency?
Answer: keep physics, collision, scoring, and AI entirely off-chain. Write only escrow and finalized outcomes on-chain, verified via a server ECDSA signature rather than a trusted call.
This separates the latency-sensitive gameplay loop from the latency-tolerant settlement layer.
System Architecture
End-to-end data flow
- Player loads
public/index.html; Socket.IO connects tobackend.js - Wallet connection uses EIP-6963 discovery (supports MetaMask, Rabby, Coinbase Wallet, etc.)
- Wallet authentication: server issues challenge nonce → client signs → server verifies signature → wallet bound to session
- Gameplay runs server-authoritative at 15ms tick; client sends WASD input, receives
updatePlayers/updateProjectilesstate, applies interpolation and prediction - For staked matches:
GameManager.createMatch(payable) → opponent joins with equal stake → match plays off-chain → server signs result payload → winner callsGameManager.submitResultwith server signature → contract verifies ECDSA, marks complete, winner claims pot minus 2.5% fee - Free-play stats flow through a separate signature-verified path into
LeaderboardRegistry
Key Technical Breakdown
Server-authoritative game loop (backend.js)
The 15ms setInterval loop handles:
- Bot AI: chase/wander state, team-based targeting, edge avoidance, shoot cooldown
- Projectile physics and movement
- Hit detection against player positions (with prior-position velocity estimation for smoother collision)
- Death, respawn, elimination logic
- Invincibility ticks - applied both on spawn and during respawn delay to prevent chain-hit edge cases
- State broadcast to all clients
Server uses SERVER_PRIVATE_KEY only for signing result payloads - it never touches the escrow directly.
Client prediction + reconciliation (frontend.js)
Client maintains local prediction of its own movement at 15ms send cadence (matching server tick). State reconciliation uses sequence numbers:
- incoming
updatePlayersincludes server sequence - client compares against last acknowledged sequence
- small deltas: smooth interpolation
- large deltas: snap correction to avoid visible rubberband
Remote players use velocity extrapolation from prior/current position pairs broadcast by the server.
Staked match lifecycle
createMatch (payable) → pending state
joinMatch (equal stake required, expiry enforced) → active
[match plays] → server signs (matchId, winner, scores, chainId)
submitResult → contract verifies ECDSA sig → completed
claimWinnings → winner receives pot − 2.5% fee
cancelMatch / claimTimeout → safety exits for pending or timed-out active matches
Replay protection is enforced via usedSignatures mapping in GameManager.
Smart contracts
GameManager.sol - escrow and staked-match lifecycle. Stake bounds: 0.001–10 ETH. Platform fee: 2.5% (platformFeeBps = 250). Owner-configurable signer key, fee, and timeout.
LeaderboardRegistry.sol - persistent stats, seasons, and achievements (first win/kill, win streaks, etc.). Normal match stats arrive via authorized call from GameManager. Free-play stats arrive via a separate signature-verified path. Owner can rotate signer and start new seasons.
GameToken.sol - ERC-20 rewards token (ARENA). Owner + authorized minters can mint; per-player daily cap; reward helpers for kills/wins/streaks.
Code Snippet
// Server signs match result so the winner can submit it to GameManager.sol
// without the server needing to hold escrow or call the contract directly.
// The contract verifies this ECDSA signature in submitResult().
async function signMatchResult(matchId, winnerAddress, playerScores) {
// Encode the payload identically to how GameManager.sol decodes it.
const messageHash = ethers.solidityPackedKeccak256(
["bytes32", "address", "uint256[]", "uint256"],
[matchId, winnerAddress, playerScores, CHAIN_ID]
);
// Prefix with Ethereum signed message header (EIP-191).
const signedHash = ethers.hashMessage(ethers.getBytes(messageHash));
const signature = await serverWallet.signMessage(ethers.getBytes(messageHash));
return { matchId, winnerAddress, playerScores, signature };
}
// REST endpoint: winner polls for their signed result after game over.
app.get("/api/match-result/:matchId", (req, res) => {
const result = signedResults.get(req.params.matchId);
if (!result) return res.status(404).json({ error: "Result not ready" });
res.json(result);
});Video Demo
Technical Specs
| Property | Value |
|---|---|
| Server tick rate | 15ms (~66.7 TPS) |
| Client input send rate | 15ms (matches server tick) |
| Respawn invincibility | 2 seconds |
| Shoot cooldown | 200ms |
| Match stake range | 0.001 – 10 ETH |
| Platform fee | 2.5% (platformFeeBps = 250) |
| Network | Base Sepolia |
| Contract verification | BaseScan |
Challenges & Solutions
Challenge 1: On-chain latency breaking post-match UX
Transaction confirmation on testnet takes 10–30 seconds. Blocking the result screen on receipt caused a dead UX pause.
Solution: Optimistic UI - display match result immediately from server state, submit the result signature asynchronously, show a confirmation indicator when the receipt arrives. Game state and settlement are fully decoupled.
Challenge 2: Wallet identity vs. gameplay identity
Using raw wallet addresses in Socket.IO payloads during gameplay exposed private information and tied gameplay identity to on-chain identity prematurely.
Solution: Server assigns an opaque session ID on connect. Wallet address is exchanged only at auth time and at match finalization - never during in-flight gameplay events.
Challenge 3: Replay attacks on signed match results
A signed result payload could be resubmitted to drain escrow a second time.
Solution: usedSignatures mapping in GameManager.sol records each consumed signature. submitResult reverts if the signature has already been used.
Known Gaps (Honest)
getPendingMatches()inGameManagerreturns an empty placeholder - off-chain indexing is expected for match discovery_updateLeaderboarduses low-levelcalland ignores return success to prevent match-flow reverts (intentional, but worth hardening)scoreis used as a kill proxy in several contract paths (deliberate simplification for prototype scope)- Duplicate
WalletManagercallback assignment exists in bothfrontend.jsandgameUI.js; last-write wins depending on execution order
What I'd Improve Next
- Add deterministic replay/log pipeline for anti-cheat audits
- Formalize match indexing via subgraph or indexed backend (replace empty
getPendingMatches) - Add end-to-end tests across the Socket.IO + contract boundary
- Harden server signer key management with HSM/KMS
- Split monolithic
frontend.jsinto typed modules for long-term maintainability