DEV Community

Het Dave
Het Dave

Posted on

Your game HUD is just React: overlay UI over a CarverJS game canvas

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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) })),
}));
Enter fullscreen mode Exit fullscreen mode

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']} />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
)}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Three layers, one component tree:

  1. Canvas — CarverJS actors, game loop, AABB collision detection
  2. HUD — React divs with score, health bar, any overlay widget
  3. 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)