Skip to Content
We're building a full JS ecosystem for Unity, with OneJS at the core (open source release soon 🚀). Also cooking up a bunch of pre-made game UIs, plus a big site revamp in progress.

scratchpad() is a very powerful utility. It allows you to easily attach a JSX function component to an Unity Editor Tab/Window.

Usage

double-pads.tsx
import { h } from "preact" import { signal } from "preact/signals" import { scratchpad } from "onejs-editor" const counter = signal(1) scratchpad("Pad 1", () => { return <div class="w-full h-full items-center justify-center bg-red-500"> <div class="text-5xl text-white text-center p-16">OneJS now works for custom Editors 🍺</div> <button class="text-2xl p-3" onClick={() => counter.value++}>Increment</button> </div> }) scratchpad("Pad 2", () => { return <div class="w-full h-full items-center justify-center bg-blue-500 text-8xl"> <div>{`${counter.value}`}</div> <div>🎂</div> </div> })

As you can see, scratchpad() is super easy to use. Preact, Tailwind, Signals, LiveReload and the rest all work just like you’d expect. Use this to quickly prototype ideas, test out Unity APIs, or just have fun with JavaScript in the Editor!

Sources

In case you are interested in the source code of the shown examples, here they are.

Click for balls-drop.tsx source

balls-drop.tsx
import { h } from "preact" import { vec2 } from "onejs/math" import { Color } from "UnityEngine" import { useEffect, useRef, useState } from "preact/hooks" import { Angle, MeshGenerationContext, PointerMoveEvent } from "UnityEngine/UIElements" export const Ballsdrop = () => { const ref = useRef<Element>() const ballsRef = useRef<any[]>([]) const animationRef = useRef<any>(null) const mouseRef = useRef<any>({ x: null, y: null }) const [ballCount, setBallCount] = useState(0) useEffect(() => { animate() ref.current!.ve.generateVisualContent = onGenerateVisualContent ref.current!.ve.MarkDirtyRepaint() return () => { cancelAnimationFrame(animationRef.current) } }, []) function addBalls() { const { width } = ref.current!.ve.resolvedStyle for (let i = 0; i < 15; i++) { const size = Math.random() * 15 + 10 ballsRef.current.push({ x: Math.random() * width, y: size, // Start at top size, vx: Math.random() * 3 - 1.5, vy: Math.random() * 2 + 2, // Downward velocity hue: Math.random() * 360 }) } setBallCount(ballsRef.current.length) } function onGenerateVisualContent(mgc: MeshGenerationContext) { var painter2D = mgc.painter2D // Draw balls ballsRef.current.forEach(ball => { painter2D.fillColor = Color.HSVToRGB(ball.hue / 360, 0.5, 1) painter2D.BeginPath() painter2D.Arc(vec2(ball.x, ball.y), ball.size, new Angle(0), new Angle(360)) painter2D.Fill() painter2D.ClosePath() }) } function pointerMove(e: PointerMoveEvent) { mouseRef.current.x = e.position.x mouseRef.current.y = e.position.y ref.current!.ve.MarkDirtyRepaint() } function pointerLeave() { mouseRef.current.x = null mouseRef.current.y = null } function handleCollisions() { const balls = ballsRef.current const len = balls.length for (let i = 0; i < len; i++) { for (let j = i + 1; j < len; j++) { const b1 = balls[i] const b2 = balls[j] // Calculate distance between ball centers const dx = b2.x - b1.x const dy = b2.y - b1.y const distance = Math.sqrt(dx * dx + dy * dy) // Combined radius (sum of both ball radii) const minDistance = b1.size + b2.size // Check for collision if (distance < minDistance) { // Collision detected - calculate response // Normal vector in direction of collision const nx = dx / distance const ny = dy / distance // Calculate relative velocity const vx = b2.vx - b1.vx const vy = b2.vy - b1.vy // Relative velocity along normal const velAlongNormal = vx * nx + vy * ny // Don't resolve if balls are moving away from each other if (velAlongNormal > 0) continue // Simple elastic collision formula const restitution = 0.85 // Bounciness factor // Impulse scalar const impulse = -(1 + restitution) * velAlongNormal / 2 // Apply impulse to velocities b1.vx -= impulse * nx b1.vy -= impulse * ny b2.vx += impulse * nx b2.vy += impulse * ny // Move balls apart to prevent sticking const overlap = minDistance - distance const moveX = nx * overlap * 0.5 const moveY = ny * overlap * 0.5 b1.x -= moveX b1.y -= moveY b2.x += moveX b2.y += moveY } } } } function animate() { const { width, height } = ref.current!.ve.resolvedStyle const balls = ballsRef.current // Apply gravity const gravity = 0.2 balls.forEach(ball => { // Update position ball.x += ball.vx ball.y += ball.vy // Apply gravity ball.vy += gravity // Apply friction ball.vx *= 0.995 ball.vy *= 0.995 // Boundary check with bounce if (ball.x - ball.size < 0) { ball.x = ball.size ball.vx *= -0.9 // Bounce with slight energy loss } else if (ball.x + ball.size > width) { ball.x = width - ball.size ball.vx *= -0.9 } if (ball.y - ball.size < 0) { ball.y = ball.size ball.vy *= -0.9 } else if (ball.y + ball.size > height) { ball.y = height - ball.size ball.vy *= -0.9 } // Slowly change color ball.hue = (ball.hue + 0.2) % 360 }) // Handle ball-to-ball collisions handleCollisions() animationRef.current = requestAnimationFrame(animate) ref.current!.ve.MarkDirtyRepaint() } return ( <div ref={ref} class="w-full h-full relative" onPointerMove={pointerMove} onPointerLeave={pointerLeave} > <button class="absolute top-5 right-5 px-4 py-2 bg-blue-500 text-white border-0 rounded cursor-pointer font-bold" onClick={addBalls} > Add Balls </button> <div class="absolute top-5 left-5 text-white font-bold xx"> {`Count: ${ballCount}`} </div> </div> ) }

Click for magnetic-particles.tsx source

magnetic-particles.tsx
import { h } from "preact" import { vec2 } from "onejs/math" import { Color } from "UnityEngine" import { useEffect, useRef } from "preact/hooks" import { Angle, MeshGenerationContext, PointerMoveEvent } from "UnityEngine/UIElements" export const MagneticParticles = ({ window }) => { const ref = useRef<Element>() const particlesRef = useRef<any[]>([]) const animationRef = useRef<any>(null) const mouseRef = useRef<any>({ x: null, y: null }); useEffect(() => { const { width, height } = window.rootVisualElement.resolvedStyle const particleCount = Math.floor((width * height) / 15000); for (let i = 0; i < particleCount; i++) { particlesRef.current.push({ x: Math.random() * width, y: Math.random() * height, size: Math.random() * 20 + 1, vx: Math.random() * 2 - 1, vy: Math.random() * 2 - 1, hue: Math.random() * 360 }); } animate() ref.current!.ve.generateVisualContent = onGenerateVisualContent ref.current!.ve.MarkDirtyRepaint() return () => { cancelAnimationFrame(animationRef.current) } }, []) function onGenerateVisualContent(mgc: MeshGenerationContext) { var painter2D = mgc.painter2D particlesRef.current.forEach(p => { painter2D.fillColor = Color.HSVToRGB(p.hue / 360, 0.5, 1) painter2D.BeginPath() painter2D.Arc(vec2(p.x, p.y), p.size, new Angle(0), new Angle(360)) painter2D.Fill() painter2D.ClosePath() }) } function pointerMove(e: PointerMoveEvent) { mouseRef.current.x = e.position.x mouseRef.current.y = e.position.y ref.current!.ve.MarkDirtyRepaint() } function pointerLeave() { mouseRef.current.x = null mouseRef.current.y = null } function animate() { const { width, height } = ref.current!.ve.resolvedStyle particlesRef.current.forEach(p => { // Update position p.x += p.vx; p.y += p.vy; // Apply friction p.vx *= 0.95; p.vy *= 0.95; // Mouse attraction if (mouseRef.current.x && mouseRef.current.y) { const dx = mouseRef.current.x - p.x; const dy = mouseRef.current.y - p.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 150) { const angle = Math.atan2(dy, dx); const force = (150 - dist) / 100; p.vx += Math.cos(angle) * force * 0.6; p.vy += Math.sin(angle) * force * 0.6; } } // Boundary check if (p.x < 0 || p.x > width) p.vx *= -1; if (p.y < 0 || p.y > height) p.vy *= -1; // Slowly change color p.hue = (p.hue + 0.2) % 360; }); animationRef.current = requestAnimationFrame(animate) ref.current!.ve.MarkDirtyRepaint() } return <div ref={ref} class="w-full h-full" onPointerMove={pointerMove} onPointerLeave={pointerLeave}></div> }