Most browser game setups separate game rendering from UI rendering. The game owns the canvas; HTML elements layer over it with careful z-index management. State crossing that boundary usually means event emitters, global flags, or a separate state system. In some frameworks, the game objects are their own DOM abstraction. In raw canvas work, the DOM overlay is entirely your problem.
CarverJS takes a different position: the game is a React subtree. The HUD is another React subtree positioned next to it. No second renderer, no event bus. Just JSX.
The structure
<Game> owns the canvas, the game loop, and the audio context. From React's perspective it is still JSX — which means you wrap it, position siblings over it with CSS, and pass state up and down through normal React patterns.
function GameScreen() {
const [score, setScore] = useState(0);
return (
<div style={{ position: 'relative', width: 800, height: 600 }}>
<Game width={800} height={600}>
<Arena onScore={() => setScore((s) => s + 1)} />
</Game>
{/* HUD — absolute div over the canvas */}
<div
style={{
position: 'absolute',
top: 16,
left: 16,
color: 'white',
fontFamily: 'monospace',
pointerEvents: 'none',
}}
>
Score: {score}
</div>
</div>
);
}
The score display is a position: absolute div. React renders both the canvas and the HUD. There is no second rendering pass, no HUD texture atlas, no DOM-over-canvas bridge.
Wiring game events to HUD state
Game events happen inside hooks that run within <Game>'s React context. To expose them outside that boundary, lift shared state into an external store.
Zustand works cleanly here:
// store.ts
import { create } from 'zustand';
interface GameState {
score: number;
hp: number;
addScore: (n: number) => void;
takeDamage: (n: number) => void;
}
export const useGameStore = create<GameState>((set) => ({
score: 0,
hp: 100,
addScore: (n) => set((s) => ({ score: s.score + n })),
takeDamage: (n) => set((s) => ({ hp: Math.max(0, s.hp - n) })),
}));
Inside the game, actor hooks write to the store on collision:
// Player.tsx
function Player() {
const playerRef = useRef(null);
const addScore = useGameStore((s) => s.addScore);
const takeDamage = useGameStore((s) => s.takeDamage);
const coinHit = useCollision(playerRef, ['coin']);
const enemyHit = useCollision(playerRef, ['enemy']);
useEffect(() => {
if (coinHit) addScore(1);
}, [coinHit]);
useEffect(() => {
if (enemyHit) takeDamage(10);
}, [enemyHit]);
return <Actor ref={playerRef} tags={['player']} />;
}
The HUD subscribes from outside <Game>:
// HUD.tsx
function HUD() {
const score = useGameStore((s) => s.score);
const hp = useGameStore((s) => s.hp);
return (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
<div style={{ position: 'absolute', top: 16, left: 16, color: 'white' }}>
<div>HP: {hp} / 100</div>
<div>Score: {score}</div>
</div>
</div>
);
}
No prop drilling. No event emitters. Standard Zustand subscriptions.
A health bar
Once hp is in the store, the health bar is a styled div:
function HealthBar({ hp, max }: { hp: number; max: number }) {
const pct = Math.round((hp / max) * 100);
return (
<div
style={{
width: 200,
height: 12,
background: '#333',
borderRadius: 4,
overflow: 'hidden',
}}
>
<div
style={{
width: `${pct}%`,
height: '100%',
background: hp < 30 ? '#e53e3e' : '#48bb78',
borderRadius: 4,
transition: 'width 120ms linear',
}}
/>
</div>
);
}
The width transition is CSS. React's reconciler handles updates. No animation library required.
Debug overlays during development
Because the HUD is React, a debug overlay is a conditional render:
{process.env.NODE_ENV === 'development' && (
<div
style={{
position: 'absolute',
bottom: 8,
right: 8,
color: 'lime',
fontSize: 11,
fontFamily: 'monospace',
pointerEvents: 'none',
}}
>
HP: {hp} | Score: {score}
</div>
)}
Same component, same store, stripped in the production build by your bundler. No debug-mode game loop variant.
The full GameScreen
function GameScreen() {
return (
<div style={{ position: 'relative', width: 800, height: 600 }}>
<Game width={800} height={600}>
<Player />
<EnemySpawner />
<CoinField />
</Game>
<HUD />
</div>
);
}
Three layers, one component tree:
- Canvas — CarverJS actors, game loop, AABB collision detection
- HUD — React divs with score, health bar, any overlay widget
- Overlays — pause screen, inventory, settings as conditional JSX
State flows through Zustand subscriptions. Components re-render when the store updates.
What this means
The HUD can use any React-ecosystem library your app already uses. React Query, Framer Motion, a CSS-in-JS system — none of that requires special integration because the HUD is just a React component tree over a canvas.
That is the design bet CarverJS makes: the game is a feature of the React app, not a runtime hosted by it.
@carverjs/core on npm, MIT licensed. v0.0.1, beta — APIs will move before 1.0.
Top comments (0)