<div id="root"></div>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
* {
  font-family: 'Press Start 2P', cursive;
}

body {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  margin: 0;
}

//button { font-family: 'Press Start 2P', cursive; }

:root {
  --brand-color: rebeccapurple;
  --accent-color: rgb(62, 93, 228);
  --text-color: rgb(40, 40, 40);
  --bg-color: rgb(240, 240, 243);
}

.App {
	background-color: #444;
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100vh;
	overflow:hidden;
	width: 100%;
}
View Compiled
const { useState, useRef, useCallback, useEffect, useLayoutEffect } = React


// https://twitter.com/xapaxa/status/1103854291578703872
// https://codepen.io/halvves/pen/JwQaVN?editors=0010
const { css, keyframes } = styled
const MOLE_LENGTH = 9
const MOLE_HEIGHT = 130
const MOLE_SPEED = .3

const APPEARANCE_INTERVAL = 1000

const MOLE = {
	default: {
		eyes: ['●', '●'],
		complexion: 'sienna',
		height: MOLE_HEIGHT
	},
	hurt: {
		eyes: ['>', '<'],
		complexion: '#f7504a',
		height: MOLE_HEIGHT
	}	
}

const TIME_LIMIT = {
	min: 1,
	sec: 0
}

const createArray = (length, elem) => {
	return new Array(length).fill(elem)
}

const useCountDownTimer = () => {
	const [timeLimit, setTimeLimit] = useState({ min: 0, sec: 0 })
	const intervalID = useRef(null)

	const formatTime = useCallback(num => {
		let result = num;

		if(num < 10) {
			result = `0${num}`
		}

		return result
	}, [])

	const initializeTime = useCallback(timeLimit => {
		const newTimeLimit = timeLimit

		if(timeLimit.min >= 60) {
			newTimeLimit.min = 0
		}

		if(timeLimit.sec >= 60) {
			newTimeLimit.sec = 0
		}

		setTimeLimit(newTimeLimit)
	}, [])

	const stopTime = useCallback(() => {
		clearInterval(intervalID.current)
	}, [intervalID.current])

	const startTime = useCallback(() => {
		const tick = () => {
			setTimeLimit(prevTimeLimit => {
				const newTimeLimit = Object.assign({}, prevTimeLimit)

				if(newTimeLimit.min > 0 && newTimeLimit.sec <= 0) {
					newTimeLimit.min = newTimeLimit.min - 1
				}

				if(newTimeLimit.min >= 0 && newTimeLimit.sec <= 0) {
					newTimeLimit.sec = 59
				}else{
					newTimeLimit.sec = (newTimeLimit.sec % 60) - 1
				}

				if(newTimeLimit.min <= 0 && newTimeLimit.sec <= 0) {
					stopTime()
				}

				return newTimeLimit
			})
		}

		intervalID.current = setInterval(tick, 1000)
	}, [])

	return [timeLimit, initializeTime, startTime, formatTime]
}

const useMoleAnimation = () => {
	const moleElementRef = useRef(null)
	
	const sinkMole = useCallback(() => {
		TweenMax.to(moleElementRef.current, .1, {y: 0})
	})

	const moveMole = useCallback(() => {
		const tween = TweenMax.to(moleElementRef.current, .5, {y: -`${MOLE_HEIGHT}`})
		tween.repeat(1)
		tween.yoyo(true)
	})

	return [moleElementRef, sinkMole, moveMole]
}

const useElementScale = () => {
	const [scale, setScale] = useState(1)
	const elementRef = useRef(null)

	const updateScale = useCallback(() => {
		const screenWidth = window.innerWidth
		const screenHeight = window.innerHeight

		const element = elementRef.current
		
		const width = element.clientWidth
		const height = element.clientHeight

		const ratio = height / width    
		const screenRatio = screenHeight / screenWidth
		
		if(screenRatio > ratio) {
			setScale(screenWidth / width)
		}else {
			setScale(screenHeight / height)
		}
	}, [])

	useLayoutEffect(() => {
		updateScale()
		window.addEventListener('resize', () => updateScale())
	})

	return [scale, elementRef]
}

const useMoles = stage => {
	const [moles, setMoles] = useState(createArray(MOLE_LENGTH, MOLE.default))

	const moveMole = useCallback(num => {
		setMoles(prevMoles => prevMoles.map(prevMole => (
			prevMole.num === num ? {
				...prevMole,
				isMove: true
			} : prevMole
		)))
	}, [])

	const initializeMoles = useCallback(() => {
		setMoles(prevMoles => prevMoles.map((prevMole, i) => ({
			...MOLE.default,
			isMove: false,
			isStruck: false,
			num: i + 1
		})))
	}, [])

	const hitMole = useCallback(num => {
		setMoles(prevMoles => prevMoles.map(prevMole => (
			prevMole.num === num ? {
				...prevMole,
				...MOLE.hurt,
				isStruck: true
			} : prevMole
		)))
	}, [])
  
	return [moles, moveMole, initializeMoles, hitMole]
}

const ButtonComponent = ({className, ...props}) => (
	<button className={className} {...props}></button>
)

const StyledButtonComponent = styled(ButtonComponent)`
	background-color: var(--brand-color);
	border: none;
	border-radius: 3px;
	color: white;
	font-size: 1em;
	padding: 1em 1.8em;
	cursor: pointer;
`

const Button = props => (
	<StyledButtonComponent {...props} />
)

const DialogComponent = ({ className, title, text, buttonName, callback }) => (
	<div className={className}>
		<div className="dialog-content">
			<div className="dialog-content-inner">
				<h1 className="dialog-title">{title}</h1>
				<p className="dialog-text">{text}</p>
				<Button
					className="dialog-button"
					onClick={callback}
				>
					{buttonName}
				</Button>
			</div>
		</div>
		<div className="dialog-overlay"></div>
	</div>
)

const StyledDialogComponent = styled(DialogComponent)`
	& .dialog-content {
		background-color: white;
    box-sizing: border-box;
		border-radius: 2em;
		color: #333;
		display: flex;
		align-items: center;
		justify-content: center;
		height: 300px;
		text-align: center;
    padding: 2em;
		position: absolute;
		top: calc(50% - 150px);
		left: calc(50% - 200px);
		width: 400px;
		z-index: 2;
	}
	
  & .dialog-title {
    font-size: 1.6em;
    margin: 0;
  }

	& .dialog-text {
		color: #777;
    font-size: .4em;
    margin: 4em 0 0;
	}
  
  & .dialog-button {
    font-size: .6em;
    margin:2em 0 0;
  }

	& .dialog-overlay {
		background-color: rgba(0, 0, 0, 0.6);
		position: fixed;
		top: 0;
		right: 0;
		bottom: 0;
		left: 0;
		z-index: 1;
	}
`

const Dialog = props => (
	<StyledDialogComponent {...props} />
)

const DisplayComponent = ({ className, title, text }) => (
	<div className={className}>{`${title}: ${text}`}</div>
)

const StyledDisplayComponent = styled(DisplayComponent)`
	font-size: 1em;
`

const Display = props => (
	<StyledDisplayComponent {...props} />
)

const MoleComponent = ({ className, mole, ...props }) => {
	const [moleElementRef, sinkMole, moveMole] = useMoleAnimation()

	useEffect(() => {
		if(mole.isMove) {
			moveMole()
		}

		if(mole.isStruck) {
			sinkMole()
		}
	}, [mole.isMove, mole.isStruck])

	return (
		<div 
			className={`${className} ${className}-${mole.num}`}
			ref={moleElementRef}
			{...props}
		>
			<div className="mole-eyes">
				{mole.eyes.map((eye, index) => (
					<div className="mole-eye" key={index}>{eye}</div>
				))}
			</div>
			<div className="mole-mouth">
				<div className="mole-nose"></div>
				<div className="mole-whiskers mole-whiskers-left">
					<span className="mole-whiskers-whisker"></span>
				</div>
				<div className="mole-whiskers mole-whiskers-right">
					<span className="mole-whiskers-whisker"></span>
				</div>
			</div>
		</div>
	)
}

const StyledMole = styled(MoleComponent)`
	background-color: ${({mole}) => mole.complexion};
	border-radius: 50% 50% 20% 20%;
	cursor: pointer;
	height: ${({mole}) => mole.height}px;
	position: absolute;
	bottom: -${({mole}) => mole.height}px;
	width: 65%;

	&::after {
		border-radius: 50% 50% 20% 20%;
		content: '';
		display: block;
		height: 100%;
		opacity: 0;
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
	}

	& .mole-eyes {
		position: absolute;
	    top: 30px;
	    left: 0;
	    right: 0;
	}

	& .mole-eye {
		border-radius: 50%;
	    color: #333;
      font-family: none;
	    font-size: 1em;
	    line-height: 0;
	    position:absolute;
	    top: 0;

	    &:nth-child(1) {
	      left: 30px;
	    }

	    &:nth-child(2) {
	      right: 30px;
	    }
	}

	& .mole-mouth {
	    background-color: tan;
	    border-radius: 50%;
	    display: block;
	    height: 40px;
	    position: absolute;
	    top: 40px;
	    left: calc(50% - (45px / 2));
	    width: 45px;
	}

	& .mole-nose {
		background-color: #333;
		border-radius: 6px;
		position: absolute;
		top: calc(50% - 5px);
		left: calc(50% - 5px);
		height: 10px;
		width: 10px;
	}

	& .mole-whiskers {
		position: absolute;
		top: 0;
		bottom: 0;
		width: 100%;

		&-left {
			left: -80%;

			&::before {
				transform: rotate(15deg);
			}

			&::after {
				transform: rotate(-15deg);
			}
		}

		&-right {
			right: -80%;

			&::before {
				transform: rotate(-15deg);
			}
			
			&::after {
				transform: rotate(15deg);
			}
		}

		&-whisker,
	    &::before,
	    &::after {
	        background-color: #fff;
	        display: block;
	        position: absolute;
	        height: 1px;
	        left: 0;
	        width: 100%;
	    }

	    &::before,
	    &::after {
	        content: '';
	    }

	    &-whisker {
	        top: calc(50% - .5px);
	    }

	    &::before {
	        top: 6px;
	    }

	    &::after {
	        bottom: 6px;
	    }
	}
`

const Mole = props => (
	<StyledMole {...props} />
)

const TextEmergeEffectComponent = ({ className, text }) => (
	<p className={className}>{text}</p>
)

const emerge = keyframes`
	0% {
        transform: scale(0.2);
        opacity: 1;
        visibility: visible;
    }
    70% {
        transform: scale(1);
    }
    100% {
        opacity: 0;
        visibility: hidden;
    }
`

const StyledTextEmergeEffectComponent = styled(TextEmergeEffectComponent)`
	animation: ${css`${emerge} 1s 1`};
	color: white;
    font-size: 1em;
    opacity: 0;
    visibility: hidden;
    text-align:center;
`

const TextEmergeEffect = props => (
	<StyledTextEmergeEffectComponent {...props} />
)

const WhackAMoleComponent = ({ className }) => {
	const [isStart, setIsStart] = useState(false)
	const [isStop, setIsStop] = useState(false)
	const [hitCount, setHitCount] = useState(0)

	const [scale, elementRef] = useElementScale()
	const [moles, moveMole, initializeMoles, hitMole] = useMoles()
	const [timeLimit, initializeTime, startTime, formatTime] = useCountDownTimer()
	
	const intervalID = useRef(null)

	const moveMoles = useCallback(() => {	
		intervalID.current = setInterval(() => {
			initializeMoles()

			const randomMoleNum = Math.floor(Math.random() * MOLE_LENGTH) + 1
			moveMole(randomMoleNum)
		}, APPEARANCE_INTERVAL)
	}, [])

	const stopMoles = useCallback(() => {
		clearInterval(intervalID.current)
	}, [])

	const startGame = useCallback(() => {
		setIsStart(true)
		startTime()
		moveMoles()
	}, [])

	const stopGame = useCallback(() => {
		setIsStop(true)
		stopMoles()
	}, [])

	const initializeGame = useCallback(() => {
		setIsStart(false)
		setIsStop(false)
		setHitCount(0)
		initializeMoles()
		initializeTime(TIME_LIMIT)		
	}, [])

	const handleHit = useCallback(e => {
		const moleElement = e.target

		const num = parseFloat(moleElement.className.replace(/[^0-9]/g, ''))
		
		hitMole(num)
		setHitCount(prevHitCount => prevHitCount + 1)
	}, [])

	useEffect(() => {
		initializeGame()
	}, [])

	useEffect(() => {
		if(isStart && timeLimit.min <= 0 && timeLimit.sec <= 0) {
			stopGame()
		}

	}, [timeLimit.min, timeLimit.sec])

	const style = {
		transform: `scale(${scale})`
	}

	return (
		<div className={className} ref={elementRef} style={style}>
			{isStop && (
				<Dialog
					className="finish-dialog"
					title="Time's up"
					text={`Your score is ${hitCount}!`}
					buttonName='finish'
					callback={initializeGame}
				/>
			)}
			{!isStart && (
				<Dialog
					className="start-dialog"
					title="Whack a Mole"
					text="Click the button to start!"
					buttonName="start"
					callback={startGame}
				/>
			)}
			<div className="game-status">
				<Display className="time-limit" title="Time limit" text={`${formatTime(timeLimit.min)}:${formatTime(timeLimit.sec)}`} />
				<Display className="score" title="Score" text={hitCount} />
			</div>
			<div className="stage">
				{moles && moles.map((mole, i) => 
					<div className="cell" key={i}>
						<div className="hole-mask">
							<div className="hole">
								{mole.isStruck &&
									<TextEmergeEffect className="hit-effect" text="hit!" />
								}
								<Mole className="mole" mole={mole} onClick={handleHit} />
							</div>
						</div>
					</div>
				)}
			</div>
		</div>
	)
}

const StyledWhackAMoleComponent = styled(WhackAMoleComponent)`
	display: flex;
	justify-content: space-between;
	flex-direction: column;
	padding: 20px;

	& .game-status {
		color: white;
		display: flex;
    font-size: .4em;
		justify-content: space-between;
		margin-bottom: 1.6em;
	}

	& .stage {
		background-color: #96D65E;
		display: flex;
		flex-wrap: wrap;
		padding: 0 40px 40px;
		position: relative;
		width: 500px;

		& .cell {
			display: flex;
			align-items: end;
			justify-content: center;
			width: calc(100% / 3);
		}
		& .hole {
			background-color: #431F07;
			border-radius: 100%;
			display: flex;
			flex-direction: column;
			align-items: center;
			justify-content: center;
			height: 100px;
			width: 88%;

			&-mask {
				border-radius: 50%;
				display: flex;
				align-items: end;
				justify-content: center;
				overflow: hidden;
				padding-top: 40px;
				position: relative;
				width: 100%;
			}
		}
		& .hit-effect {
			font-size: 1em;
		}
	}
`

const WhackAMole = props => (
	<StyledWhackAMoleComponent {...props} />
)

const App = () => (
	<div className="App">
		<WhackAMole className="whack-a-mole" />
	</div>
)

ReactDOM.render(<App />, document.getElementById('root'))
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js
  3. https://unpkg.com/styled-components@4.1.2/dist/styled-components.min.js
  4. https://cdnjs.cloudflare.com/ajax/libs/gsap/2.0.2/TweenMax.min.js