Turn-based tabletop box hockey on one screen: flick the puck through the gaps and into the other player's goal, against the CPU or a friend across the table.
BoxHockey is a physics-driven box hockey game for iOS, Android, and web. Built with Expo, React Native, and React Native Skia, it's part of a family of games built on my open-source @tastic game-engine and @rific Expo utility packages. App Store and Google Play release in progress.
- Two modes: One Player against a CPU opponent (Easy, Medium, Hard), or Two Player pass-and-play with both people on the same device. Players take turns shooting. After a goal, Coin Flip mode hands the puck to the player who conceded, in their own zone; Face-Off and Triple Tap restart from center with a new draw.
- Three ways to shoot, per player: Flick (power comes from swipe speed, the default), Pull (slingshot: drag back, release to fire), or Push (drag toward the shot). Each seat picks its own, and a saved profile remembers its choice.
- Three tip-off modes: Face-Off, a reaction race (both players ready up, a 3-2-1 countdown runs, then one of each player's two circles lights after a random hold and the first correct tap wins the knock); Coin Flip, a tap-to-flip animation for the opening possession; and Triple Tap (iOS and Android only), where each player taps their zone twice and then swipes, and the two swipes add together into the launch.
- Board variants and modifiers: Classic (a center divider with two gaps), Gauntlet (three gapped walls splitting the rink into four zones), and Gravity (the classic divider plus a center attractor and four corner repellers). A Rink toggle adds rounded corners and a net pocket behind each goal on any of the three.
- Match options: puck speed (friction), puck size (the goal and wall gaps scale with it), score to win (3, 5, 7, or 10), a shot preview (Off by default, or Short, Medium, or Long, where the longer lengths draw predicted bank shots), and a Slap Shot setting (Patient, Alert, or Aggressive) that sets how early the receiving player can hit a puck that's still sliding. The loadout also has randomize and reset buttons.
- Player profiles: saved names, colors, tags, and shot style per seat. On iOS the roster is shared with my other games through an App Group.
- Stats and achievements: per-profile and device-wide records (vs CPU by difficulty, two-player results by seat, wins per board variant, goals, shutouts, best win margin, win and day streaks, player rankings) and 33 bronze/silver/gold achievements.
- How-to-play guide: a short first-run guide, replayable from Settings, built from the live settings: the shot card describes each seat's actual control scheme and the tip-off card matches the selected mode.
- Settings: sound, haptics, appearance, an app-wide Lock Orientation setting, Edge Guard (an iOS option that makes system edge gestures need a second swipe during play), and Check for Updates. A match pauses automatically when the app goes to the background and stays Paused until a player taps Resume.
- Physics: a custom puck integrator (
src/utils/physics.ts) built on@tastic/physicscollision primitives (circle vs. rect, circle vs. circle, rounded corners, reflection, friction, gravity wells). Fast pucks are sub-stepped to avoid tunneling, a goal only counts once the puck's whole body has crossed the line, and goalposts are round so they deflect along a curve. With Rink on, the goal mouth is a one-way door: a puck can go in and around the pocket but can't come back out the front. A shot taken at a still-moving puck adds the puck's existing velocity to the shot. - AI opponent by simulation: the CPU doesn't use an angle formula. It replays candidate shots through the same
stepPhysicsthe game loop runs, over a grid of angles and power levels, and scores each result: a goal (fewer bounces and a straighter line rank higher), an own goal (discarded unless every candidate is one), or where the puck stops. Difficulty sets how wide it searches, whether it tries bank shots (Hard sweeps a full circle), how much it weighs defense, its aim and power noise, and its thinking time. It plays its turn through the same aim/shoot input path a human drag uses, so the stick and preview render the same way for both. - Face-off resolution (
src/utils/faceoff.ts): the winner's knock launches from center toward the loser's goal. Two inputs drive it separately: the margin between the two presses sets the angle jitter (a close race gives a scrappier, wider knock), and the winner's own reaction time sets the power. Jitter is capped well under 90° so the knock can't head back toward the winner's own net, and the loser always takes the next real turn. Each player also gets a blind 50/50 guess at which circle will light, worth a small reaction-time bonus. The CPU's reaction times follow real human reaction-time ranges, and on Easy and Medium it sometimes taps the wrong circle. - Turn-based reducer: a single
useGameStatereducer (src/hooks/useGameState.ts) holds every phase (aiming, striking, simulating, scored, face-off, coin flip, triple tap, game over), driven by@tastic/core's game loop. Random choices (which circle lights, the coin flip winner), timestamps, and the frame delta are all passed in by the caller, so the reducer itself stays deterministic and testable. - Board geometry (
src/utils/board.ts): the goal and wall gaps are sized from the current puck radius, with more margin on larger pucks, so every tier stays makeable. On native and mobile web the board fills the screen edge to edge, so bank shots bounce off the real screen edge; desktop web fixes the board to a real box hockey table's 3.3:8 ratio and draws perimeter walls. When the device is turned upside down the board flips 180° and touch input is mapped back into board coordinates, while the menus and loadout reflow for landscape. - Split-screen loadout: in two-player the loadout uses
@tastic/split-screento lay out both players' panels facing each other or side by side, following the device's tilt. - Skia rendering across platforms: the rink, puck, posts, score wedges around each crease, goal celebrations, gravity-well swirls, and the coin flip are drawn with React Native Skia and
@tastic/sprites.BoardHost.web.tsxlazy-loads the board only after CanvasKit (WASM) is ready, while the nativeBoardHost.tsxis a passthrough. - State and updates: Redux Toolkit with redux-persist covers settings, profiles, seat colors, per-profile shot style, stats, achievements, and guide state, with per-round state left out of persistence. A persist
migraterecognizes players from the pre-Redux build so they skip the guide. Over-the-air updates ship throughexpo-updatesand@rific/updater. - Tests: 16 Jest suites with 298 tests covering physics and rink geometry, the AI planner, face-off knocks, the slap-shot intercept path, Triple Tap sequencing, a turn-alternation fuzz test, stats and achievements, settings and guide persistence, background pause, and profile syncing.
| Package | Used for |
|---|---|
@tastic/core |
Game loop, orientation and rotation handling, settled window sizing for the web canvas, background pause, config plugin for the native portrait lock |
@tastic/physics |
Circle/rect/rounded-corner collision, reflection, friction, gravity wells, goal-wedge geometry |
@tastic/input |
Drag-to-aim vector math |
@tastic/split-screen |
Two-player loadout zone layout (face-to-face vs side-by-side) |
@tastic/hud |
Player setup panels, dropdowns, settings, confirm and update dialogs, stats screen sections, how-to-play guide, Skia web loader |
@tastic/sprites |
Skia board shapes, hockey rink visuals (score wedges, goal-door flash, zone lines), gravity-well swirl |
@tastic/profile |
Player profiles, picker, roster screen, per-profile extensions, shared App Group sync |
@tastic/achievements |
Tiered achievement catalog, streak tracking, Redux-backed persistence |
@tastic/edge-guard |
Optional guard against iOS edge system gestures during play |
@rific/core |
Redux settings slice factory, safe back navigation |
@rific/auto-paper |
React Native Paper theming from seed colors, light/dark appearance |
@rific/feedback-press |
Buttons with haptic and sound feedback, vibration, game sounds |
@rific/toaster |
Toast notifications (achievement unlocks, update errors) |
@rific/splash-gate |
Keeps the splash screen up until the theme, fonts, and profiles are ready |
@rific/updater |
OTA update checks and the build/update scripts |
@rific/focus-chain |
Peer dependency of @tastic/profile for form focus |
npm install
npm run ios # or: npm run android / npm start (web)The app uses native code that Expo Go doesn't include (the @tastic/core and @tastic/edge-guard config plugins and @tastic/profile's native iOS shared-roster module), so iOS and Android run as a development build (expo-dev-client). npm run ios and npm run android build it locally (expo run:*), and npm run client starts the dev server for an already-installed build. npm install also runs setup-skia-web (the postinstall script), which copies Skia's CanvasKit WASM into public/ for the web target.
npm run verify # lint (expo lint) + Jest + typecheck (tsc)CI runs the same command on pushes and pull requests to main, and npm run build / npm run update (via @rific/updater) run it before building or publishing.
src/
app/ expo-router screens: home, loadout, game, profiles, achievements
components/ board rendering, gravity-well marker, loadout controls, dialogs, theme, guide host and art
constants/ game tuning, achievement catalog, guide steps, fonts, release info
hooks/ game state reducer and loop, AI opponent, face-off and Triple Tap timers, profiles, stats, settings, sound
redux/ redux-persist store and slices (settings, profiles, per-profile shot style, seat colors, achievements, guide, live play)
testUtils/ shared test helpers
types/ shared TypeScript types
utils/ physics, board geometry, AI, face-off knock, game mode helpers, stats engine and validation
__tests__/ Jest suites
__mocks__/ manual mocks for native and Expo modules
The code is released under the MIT License, see LICENSE.
The game's name, logo, and icons, and the original artwork and audio in this repository, are not covered by that license and remain all rights reserved. If you fork it, please give your version its own name and look.
