component

TableTennis

A regulation table tennis table with its lines, a net, a folding undercarriage, and paddles and a ball left on it.

⌖ Explore the library in 3D → · View as Markdown

Tune every prop in the workshop →

Add it

npx @runek/cli add tabletennis

Pulls @react-three/rapier@^2.2.0, @runek/core@^0.15.0.

Use it

import { TableTennis } from './runek/TableTennis'

<TableTennis position={[0, 0, 0]} seed={1} />

Or as a node in a world file (worlds as data):

{ "type": "TableTennis", "props": { "position": [0, 0, 0], "seed": 1 } }

Props

Prop Type Default Description
color color The playing top. Seeded (blue or green) when unset.
lineColor color "#f4f4f4" White lines along the edges and down the middle.
frameColor color Legs and net posts; defaults to the palette’s metal.
paddles boolean true Two paddles lying on the table.
ball boolean true A ball on the table.
position [x, y, z] [0, 0, 0] Position [x, y, z] in units (1 unit = 1 m, Y-up).
rotation [x, y, z] [0, 0, 0] Euler rotation [x, y, z] in radians.
seed number 1 Seed for the deterministic variation: same seed, same result.

Registry manifest

registry manifest /r/components/tabletennis.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add tabletennis fetches.

Browse the whole catalog in the gallery →.

Source (TableTennis.tsx)
import { CuboidCollider, RigidBody } from '@react-three/rapier'
import { pick, range, rng, sub, useWorld, type WorldComponentProps } from '@runek/core'
import { useMemo } from 'react'

export interface TableTennisProps extends WorldComponentProps {
  /** The playing top. Seeded (blue or green) when unset. */
  color?: string
  /** White lines along the edges and down the middle. */
  lineColor?: string
  /** Legs and net posts; defaults to the palette's `metal`. */
  frameColor?: string
  /** Two paddles lying on the table. */
  paddles?: boolean
  /** A ball on the table. */
  ball?: boolean
}

const TOPS = ['#1f4f8a', '#1f4f8a', '#2f6b4a', '#2b3a4f']
// Regulation size in meters: 2.74 long (local X), 1.525 wide, the top 0.76 up.
const L = 2.74
const W = 1.525
const H = 0.76
const T = 0.025

/**
 * A table tennis table at regulation size, long along local X: a painted top with its white
 * lines, a net with posts across the middle, a folding undercarriage, and a pair of paddles and a
 * ball left on it at seeded spots. One cuboid collider for the table; it stands on the ground.
 */
export function TableTennis({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  seed = 1,
  color,
  lineColor = '#f4f4f4',
  frameColor,
  paddles = true,
  ball = true,
}: TableTennisProps) {
  const { unit, palette } = useWorld()
  const u = unit
  const frame = frameColor ?? palette.metal

  const look = useMemo(() => {
    const r = rng(sub(seed, 0))
    const side = r() < 0.5 ? -1 : 1
    return {
      top: color ?? pick(r, TOPS),
      paddles: [
        { x: side * range(r, 0.5, 1.1), z: range(r, -0.5, 0.5), turn: r() * Math.PI * 2 },
        { x: -side * range(r, 0.5, 1.1), z: range(r, -0.5, 0.5), turn: r() * Math.PI * 2 },
      ],
      ball: { x: (r() < 0.5 ? -1 : 1) * range(r, 0.15, 1.15), z: range(r, -0.6, 0.6) },
    }
  }, [seed, color])

  const top = H * u
  const line = 0.02 * u
  const lines: { pos: [number, number]; size: [number, number] }[] = [
    { pos: [0, (W / 2 - 0.01) * u], size: [L * u, line] },
    { pos: [0, -(W / 2 - 0.01) * u], size: [L * u, line] },
    { pos: [(L / 2 - 0.01) * u, 0], size: [line, W * u] },
    { pos: [-(L / 2 - 0.01) * u, 0], size: [line, W * u] },
    { pos: [0, 0], size: [L * u, 0.003 * u] },
  ]
  const legX = (L / 2 - 0.35) * u
  const legZ = (W / 2 - 0.12) * u
  const netH = 0.1525 * u
  const netW = (W + 0.3) * u

  return (
    <RigidBody type="fixed" colliders={false} position={position} rotation={rotation}>
      <CuboidCollider args={[(L / 2) * u, top / 2, (W / 2) * u]} position={[0, top / 2, 0]} />
      <mesh position={[0, top - (T * u) / 2, 0]} castShadow receiveShadow>
        <boxGeometry args={[L * u, T * u, W * u]} />
        <meshStandardMaterial color={look.top} roughness={0.55} />
      </mesh>
      {lines.map((l) => (
        <mesh
          key={`${l.pos[0]}:${l.pos[1]}`}
          position={[l.pos[0], top + 0.0005 * u, l.pos[1]]}
          rotation={[-Math.PI / 2, 0, 0]}
        >
          <planeGeometry args={l.size} />
          <meshStandardMaterial color={lineColor} roughness={0.5} />
        </mesh>
      ))}

      {/* the undercarriage: four legs braced in pairs, and a center rail */}
      {[-1, 1].map((sx) => (
        <group key={sx} position={[sx * legX, 0, 0]}>
          {[-1, 1].map((sz) => (
            <mesh key={sz} position={[0, (top - T * u) / 2, sz * legZ]} castShadow>
              <boxGeometry args={[0.04 * u, top - T * u, 0.04 * u]} />
              <meshStandardMaterial color={frame} metalness={0.4} roughness={0.5} />
            </mesh>
          ))}
          <mesh position={[0, 0.15 * u, 0]}>
            <boxGeometry args={[0.03 * u, 0.03 * u, legZ * 2]} />
            <meshStandardMaterial color={frame} metalness={0.4} roughness={0.5} />
          </mesh>
          {[-1, 1].map((sz) => (
            <mesh key={`w${sz}`} position={[0, 0.03 * u, sz * legZ]} rotation={[0, 0, Math.PI / 2]}>
              <cylinderGeometry args={[0.03 * u, 0.03 * u, 0.025 * u, 12]} />
              <meshStandardMaterial color="#1b1b1e" roughness={0.8} />
            </mesh>
          ))}
        </group>
      ))}
      <mesh position={[0, top - T * u - 0.04 * u, 0]}>
        <boxGeometry args={[legX * 2, 0.05 * u, 0.04 * u]} />
        <meshStandardMaterial color={frame} metalness={0.4} roughness={0.5} />
      </mesh>

      {/* the net: a dark mesh panel with a white tape on top, clamped by two posts */}
      <mesh position={[0, top + netH / 2, 0]}>
        <boxGeometry args={[0.004 * u, netH, netW]} />
        <meshStandardMaterial color="#1a1c22" roughness={0.9} transparent opacity={0.75} />
      </mesh>
      <mesh position={[0, top + netH - 0.008 * u, 0]}>
        <boxGeometry args={[0.006 * u, 0.016 * u, netW]} />
        <meshStandardMaterial color={lineColor} roughness={0.6} />
      </mesh>
      {[-1, 1].map((s) => (
        <mesh key={`post${s}`} position={[0, top + netH / 2 - 0.01 * u, s * (netW / 2)]}>
          <cylinderGeometry args={[0.008 * u, 0.008 * u, netH + 0.02 * u, 8]} />
          <meshStandardMaterial color={frame} metalness={0.5} roughness={0.4} />
        </mesh>
      ))}

      {paddles &&
        look.paddles.map((p, i) => (
          <group
            key={`${p.x.toFixed(3)}:${p.z.toFixed(3)}`}
            position={[p.x * u, top + 0.006 * u, p.z * u]}
            rotation={[0, p.turn, 0]}
          >
            <mesh castShadow>
              <cylinderGeometry args={[0.075 * u, 0.075 * u, 0.008 * u, 24]} />
              <meshStandardMaterial color={i ? '#1b1b1e' : '#c0262d'} roughness={0.7} />
            </mesh>
            <mesh position={[0.1 * u, 0.003 * u, 0]} castShadow>
              <boxGeometry args={[0.1 * u, 0.02 * u, 0.028 * u]} />
              <meshStandardMaterial color="#c9a774" roughness={0.6} />
            </mesh>
          </group>
        ))}
      {ball && (
        <mesh position={[look.ball.x * u, top + 0.02 * u, look.ball.z * u]} castShadow>
          <sphereGeometry args={[0.02 * u, 14, 10]} />
          <meshStandardMaterial color="#f7f4ee" roughness={0.4} />
        </mesh>
      )}
    </RigidBody>
  )
}

TableTennis.groundSitting = true