Skip to content

Instantly share code, notes, and snippets.

@mildlyautistic
Created August 16, 2026 11:19
Show Gist options
  • Select an option

  • Save mildlyautistic/33a6f98e00f3031dd3df02da217ba68e to your computer and use it in GitHub Desktop.

Select an option

Save mildlyautistic/33a6f98e00f3031dd3df02da217ba68e to your computer and use it in GitHub Desktop.
A vibe coded simple grid painter for planning game levels.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Infinite Grid Painter - Advanced</title>
<style>
body, html {
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; font-family: sans-serif; background-color: #111;
}
#toolbar {
position: absolute; top: 0; left: 0; width: 100%;
background: #222; padding: 10px; display: flex; gap: 10px;
align-items: center; box-sizing: border-box;
border-bottom: 1px solid #444; color: white; z-index: 10;
flex-wrap: wrap;
}
.color-btn {
width: 26px; height: 26px; border: 2px solid transparent;
border-radius: 4px; cursor: pointer;
}
.color-btn.active { border-color: white; outline: 2px solid #aaa; }
.divider { width: 2px; height: 26px; background: #444; margin: 0 5px; }
button {
padding: 6px 12px; background: #444; color: white;
border: none; border-radius: 4px; cursor: pointer; font-size: 13px;
}
button:hover { background: #555; }
button.tool.active { background: #666; outline: 2px solid white; }
.action { background: #007bff; font-weight: bold; }
.action:hover { background: #0056b3; }
.danger { background: #dc3545; }
.danger:hover { background: #a71d2a; }
#selection-actions { display: none; gap: 10px; }
canvas { display: block; }
/* Dialog Styles */
dialog {
background: #2a2a2a; color: white; border: 1px solid #555;
border-radius: 8px; padding: 20px; width: 300px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
.dialog-group { margin-bottom: 15px; }
.dialog-group label { display: block; margin-bottom: 5px; color: #ccc; font-size: 13px; }
.dialog-group input, .dialog-group select {
width: 100%; box-sizing: border-box; background: #111; color: white;
border: 1px solid #444; padding: 8px; border-radius: 4px;
}
</style>
</head>
<body>
<div id="toolbar">
<span>Colors:</span>
<div class="color-btn active" style="background: #ff3b30;" data-color="#ff3b30"></div>
<div class="color-btn" style="background: #34c759;" data-color="#34c759"></div>
<div class="color-btn" style="background: #007aff;" data-color="#007aff"></div>
<div class="color-btn" style="background: #ffcc00;" data-color="#ffcc00"></div>
<div class="color-btn" style="background: #ffffff;" data-color="#ffffff"></div>
<div class="color-btn" style="background: #000000;" data-color="#000000"></div>
<div class="divider"></div>
<button class="tool active" data-tool="paint" title="Shortcut: 1">Paint (1)</button>
<button class="tool" data-tool="select" title="Shortcut: 2">Select (2)</button>
<button class="tool" data-tool="text" title="Shortcut: 3">Add Note (3)</button>
<button class="tool" data-tool="marker" title="Shortcut: 4">Add Marker (4)</button>
<div class="divider"></div>
<span>Zoom:</span>
<button id="btn-zoom-out">-</button>
<button id="btn-zoom-reset">100%</button>
<button id="btn-zoom-in">+</button>
<div class="divider"></div>
<span>Rotate:</span>
<button id="btn-rot-ccw" title="Rotate Left 15°"></button>
<button id="btn-rot-cw" title="Rotate Right 15°"></button>
<button id="btn-rot-reset" class="action" style="display:none;">Reset Rotation</button>
<div class="divider"></div>
<div id="selection-actions">
<button class="action" id="btn-fill">Fill Selection</button>
<button class="danger" id="btn-clear">Clear Selection</button>
</div>
<button class="action" style="margin-left: auto;" id="btn-export">Export PNG</button>
</div>
<canvas id="gridCanvas"></canvas>
<dialog id="marker-dialog">
<h3 id="marker-dialog-title" style="margin-top: 0;">Add Marker</h3>
<div class="dialog-group">
<label>Icon:</label>
<select id="marker-icon">
<option value="🚩">🚩 Flag</option>
<option value="👾">👾 Enemy / Monster</option>
<option value="💰">💰 Loot / Gold</option>
<option value="🚪">🚪 Door / Exit</option>
<option value="🔑">🔑 Key</option>
<option value="⭐️">⭐️ Star / Goal</option>
<option value="💀">💀 Danger / Skull</option>
<option value="⚔️">⚔️ Combat / Weapon</option>
<option value="🛡️">🛡️ Shield / Armor</option>
<option value="❤️">❤️ Health / Restore</option>
</select>
</div>
<div class="dialog-group">
<label>Label (Optional):</label>
<input type="text" id="marker-label" placeholder="e.g. Boss Room">
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 25px;">
<button class="danger" id="btn-marker-delete" style="display: none; margin-right: auto;">Delete</button>
<button id="btn-marker-cancel">Cancel</button>
<button class="action" id="btn-marker-save">Save</button>
</div>
</dialog>
<script>
const canvas = document.getElementById('gridCanvas');
const ctx = canvas.getContext('2d');
// Configuration & State
const BASE_CELL_SIZE = 20;
let zoom = 1;
let rotation = 0; // NEW: Track rotation in radians
let camera = { x: 0, y: 0 };
const cells = new Map();
const notes = [];
let noteIdCounter = 0;
const markers = [];
let markerIdCounter = 0;
const history = [];
const MAX_HISTORY = 50;
let currentColor = '#ff3b30';
let currentTool = 'paint';
let isDragging = false;
let isPanning = false;
let lastCell = null;
let lastMouse = { x: 0, y: 0 };
let selection = null;
let draggingNote = null;
let noteDragOffset = { x: 0, y: 0 };
let pendingMarkerGridPos = null;
let editingMarker = null;
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
draw();
}
window.addEventListener('resize', resize);
// --- MATH / TRANSFORM HELPERS ---
function getCellSize() { return BASE_CELL_SIZE * zoom; }
// NEW: Inverse transforms screen clicks to logical grid space when rotated
function getTransformedMouse(screenX, screenY) {
if (rotation === 0) return { x: screenX, y: screenY };
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const dx = screenX - cx;
const dy = screenY - cy;
const invRot = -rotation; // Inverse rotation
const nx = dx * Math.cos(invRot) - dy * Math.sin(invRot) + cx;
const ny = dx * Math.sin(invRot) + dy * Math.cos(invRot) + cy;
return { x: nx, y: ny };
}
function getGridCoords(screenX, screenY) {
const tm = getTransformedMouse(screenX, screenY);
const size = getCellSize();
return {
x: Math.floor((tm.x - camera.x) / size),
y: Math.floor((tm.y - camera.y) / size)
};
}
function getScreenCoords(gridX, gridY) {
const size = getCellSize();
return {
x: gridX * size + camera.x,
y: gridY * size + camera.y
};
}
// --- STATE MANAGEMENT ---
function saveState() {
const state = {
cells: Array.from(cells.entries()),
notes: JSON.parse(JSON.stringify(notes)),
markers: JSON.parse(JSON.stringify(markers))
};
history.push(state);
if (history.length > MAX_HISTORY) history.shift();
}
function undo() {
if (history.length === 0) return;
const state = history.pop();
cells.clear();
state.cells.forEach(([k, v]) => cells.set(k, v));
notes.length = 0;
notes.push(...state.notes);
markers.length = 0;
markers.push(...state.markers);
draw();
}
// --- ROTATION UI ---
const ROTATION_STEP = 15 * Math.PI / 180;
const btnRotReset = document.getElementById('btn-rot-reset');
function updateRotationUI() {
if (Math.abs(rotation % (Math.PI * 2)) > 0.001) {
btnRotReset.style.display = 'inline-block';
} else {
btnRotReset.style.display = 'none';
rotation = 0; // snap to exact 0
}
}
document.getElementById('btn-rot-ccw').addEventListener('click', () => {
rotation -= ROTATION_STEP;
updateRotationUI();
draw();
});
document.getElementById('btn-rot-cw').addEventListener('click', () => {
rotation += ROTATION_STEP;
updateRotationUI();
draw();
});
btnRotReset.addEventListener('click', () => {
rotation = 0;
updateRotationUI();
draw();
});
// --- DRAWING ---
function draw(exportCtx = null, exportCamera = null, exportZoom = null) {
const context = exportCtx || ctx;
const cam = exportCamera || camera;
const z = exportZoom || zoom;
const size = BASE_CELL_SIZE * z;
const width = exportCtx ? exportCtx.canvas.width : canvas.width;
const height = exportCtx ? exportCtx.canvas.height : canvas.height;
context.fillStyle = '#000000';
context.fillRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2;
// Apply rotation (Exports force 0 rotation to keep PNGs square)
const isRotated = rotation !== 0 && !exportCtx;
if (isRotated) {
context.save();
context.translate(cx, cy);
context.rotate(rotation);
context.translate(-cx, -cy);
}
// Expand drawing bounds if rotated to prevent clipping corners
const diag = Math.sqrt(width * width + height * height);
const diffX = isRotated ? (diag - width) / 2 : 0;
const diffY = isRotated ? (diag - height) / 2 : 0;
context.strokeStyle = '#222222';
context.lineWidth = 1;
const startX = Math.floor((-cam.x - diffX) / size) * size;
const startY = Math.floor((-cam.y - diffY) / size) * size;
const endX = width + diffX + size;
const endY = height + diffY + size;
context.beginPath();
for (let x = startX; x < endX; x += size) {
context.moveTo(x + (cam.x % size), -diffY);
context.lineTo(x + (cam.x % size), height + diffY);
}
for (let y = startY; y < endY; y += size) {
context.moveTo(-diffX, y + (cam.y % size));
context.lineTo(width + diffX, y + (cam.y % size));
}
context.stroke();
cells.forEach((color, key) => {
const [gx, gy] = key.split(',').map(Number);
const px = gx * size + cam.x;
const py = gy * size + cam.y;
if (px + size > -diffX && px < endX && py + size > -diffY && py < endY) {
context.fillStyle = color;
context.fillRect(Math.floor(px), Math.floor(py), Math.ceil(size), Math.ceil(size));
}
});
if (selection && !exportCtx) {
const minX = Math.min(selection.startX, selection.endX);
const maxX = Math.max(selection.startX, selection.endX);
const minY = Math.min(selection.startY, selection.endY);
const maxY = Math.max(selection.startY, selection.endY);
const p1 = getScreenCoords(minX, minY);
const p2 = getScreenCoords(maxX + 1, maxY + 1);
context.fillStyle = 'rgba(0, 123, 255, 0.3)';
context.fillRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
context.strokeStyle = '#007bff';
context.lineWidth = 2;
context.setLineDash([5, 5]);
context.strokeRect(p1.x, p1.y, p2.x - p1.x, p2.y - p1.y);
context.setLineDash([]);
}
context.textBaseline = 'top';
markers.forEach(marker => {
const px = marker.x * size + cam.x;
const py = marker.y * size + cam.y;
context.font = `${Math.max(14, 18 * z)}px sans-serif`;
context.fillText(marker.icon, px + (size * 0.1), py + (size * 0.1));
if (marker.label) {
context.font = `${Math.max(10, 12 * z)}px sans-serif`;
const labelWidth = context.measureText(marker.label).width;
context.fillStyle = 'rgba(0,0,0,0.7)';
context.fillRect(px, py + size + 2, labelWidth + 4, (12 * z) + 4);
context.fillStyle = marker.color;
context.fillText(marker.label, px + 2, py + size + 4);
}
});
context.font = `${Math.max(12, 16 * z)}px sans-serif`;
notes.forEach(note => {
const px = note.x * size + cam.x;
const py = note.y * size + cam.y;
const textWidth = context.measureText(note.text).width;
context.fillStyle = 'rgba(0,0,0,0.6)';
context.fillRect(px, py, textWidth + 8, (16 * z) + 8);
context.fillStyle = note.color;
context.fillText(note.text, px + 4, py + 4);
});
if (isRotated) {
context.restore();
}
}
// --- INTERACTIONS ---
function handleZoom(amount, mouseX = canvas.width / 2, mouseY = canvas.height / 2) {
const newZoom = Math.min(Math.max(0.1, zoom * amount), 10);
const tm = getTransformedMouse(mouseX, mouseY); // Account for rotation offset
camera.x = tm.x - (tm.x - camera.x) * (newZoom / zoom);
camera.y = tm.y - (tm.y - camera.y) * (newZoom / zoom);
zoom = newZoom;
draw();
}
function getNoteAtScreenCoords(screenX, screenY) {
const tm = getTransformedMouse(screenX, screenY);
ctx.font = `${Math.max(12, 16 * zoom)}px sans-serif`;
const size = getCellSize();
for (let i = notes.length - 1; i >= 0; i--) {
const note = notes[i];
const px = note.x * size + camera.x;
const py = note.y * size + camera.y;
const textWidth = ctx.measureText(note.text).width;
const textHeight = 16 * zoom;
if (tm.x >= px && tm.x <= px + textWidth + 8 && tm.y >= py && tm.y <= py + textHeight + 8) {
return note;
}
}
return null;
}
function getMarkerAtScreenCoords(screenX, screenY) {
const tm = getTransformedMouse(screenX, screenY);
const size = getCellSize();
for (let i = markers.length - 1; i >= 0; i--) {
const marker = markers[i];
const px = marker.x * size + camera.x;
const py = marker.y * size + camera.y;
if (tm.x >= px && tm.x <= px + size && tm.y >= py && tm.y <= py + size) {
return marker;
}
}
return null;
}
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomAmount = e.deltaY < 0 ? 1.1 : (1 / 1.1);
handleZoom(zoomAmount, e.clientX, e.clientY);
});
canvas.addEventListener('mousedown', (e) => {
if (e.button === 2 || e.button === 1) {
isPanning = true;
lastMouse = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'grabbing';
return;
}
if (e.button === 0) {
const clickedNote = getNoteAtScreenCoords(e.clientX, e.clientY);
if (clickedNote) {
saveState();
draggingNote = clickedNote;
const tm = getTransformedMouse(e.clientX, e.clientY);
const size = getCellSize();
noteDragOffset = {
x: (tm.x - camera.x) / size - clickedNote.x,
y: (tm.y - camera.y) / size - clickedNote.y
};
return;
}
const clickedMarker = getMarkerAtScreenCoords(e.clientX, e.clientY);
if (clickedMarker) {
editingMarker = clickedMarker;
document.getElementById('marker-dialog-title').innerText = 'Edit Marker';
document.getElementById('marker-icon').value = clickedMarker.icon;
document.getElementById('marker-label').value = clickedMarker.label;
document.getElementById('btn-marker-delete').style.display = 'block';
document.getElementById('marker-dialog').showModal();
return;
}
const gridPos = getGridCoords(e.clientX, e.clientY);
if (currentTool === 'paint') {
saveState();
isDragging = true;
lastCell = gridPos;
cells.set(`${gridPos.x},${gridPos.y}`, currentColor);
selection = null;
document.getElementById('selection-actions').style.display = 'none';
draw();
} else if (currentTool === 'select') {
isDragging = true;
selection = { startX: gridPos.x, startY: gridPos.y, endX: gridPos.x, endY: gridPos.y };
document.getElementById('selection-actions').style.display = 'flex';
draw();
} else if (currentTool === 'text') {
const text = prompt("Enter your note:");
if (text) {
saveState();
notes.push({ id: noteIdCounter++, x: gridPos.x, y: gridPos.y, text: text, color: currentColor });
draw();
}
} else if (currentTool === 'marker') {
pendingMarkerGridPos = gridPos;
editingMarker = null;
document.getElementById('marker-dialog-title').innerText = 'Add Marker';
document.getElementById('marker-icon').value = '🚩';
document.getElementById('marker-label').value = '';
document.getElementById('btn-marker-delete').style.display = 'none';
document.getElementById('marker-dialog').showModal();
}
}
});
canvas.addEventListener('mousemove', (e) => {
if (isPanning) {
const dx = e.clientX - lastMouse.x;
const dy = e.clientY - lastMouse.y;
// Adjust pan movement according to inverse rotation
if (rotation !== 0) {
const invRot = -rotation;
camera.x += dx * Math.cos(invRot) - dy * Math.sin(invRot);
camera.y += dx * Math.sin(invRot) + dy * Math.cos(invRot);
} else {
camera.x += dx;
camera.y += dy;
}
lastMouse = { x: e.clientX, y: e.clientY };
draw();
} else if (draggingNote) {
const tm = getTransformedMouse(e.clientX, e.clientY);
const size = getCellSize();
draggingNote.x = ((tm.x - camera.x) / size) - noteDragOffset.x;
draggingNote.y = ((tm.y - camera.y) / size) - noteDragOffset.y;
draw();
} else if (isDragging) {
const currentCell = getGridCoords(e.clientX, e.clientY);
if (currentTool === 'paint') {
if (currentCell.x !== lastCell.x || currentCell.y !== lastCell.y) {
let cx = lastCell.x, cy = lastCell.y;
while (cx !== currentCell.x || cy !== currentCell.y) {
if (Math.abs(currentCell.x - cx) > Math.abs(currentCell.y - cy)) cx += Math.sign(currentCell.x - cx);
else cy += Math.sign(currentCell.y - cy);
cells.set(`${cx},${cy}`, currentColor);
}
lastCell = currentCell;
draw();
}
} else if (currentTool === 'select') {
selection.endX = currentCell.x;
selection.endY = currentCell.y;
draw();
}
}
});
canvas.addEventListener('mouseup', () => {
isDragging = false;
isPanning = false;
draggingNote = null;
canvas.style.cursor = 'crosshair';
});
canvas.addEventListener('contextmenu', e => e.preventDefault());
// --- KEYBOARD & UI LISTENERS ---
window.addEventListener('keydown', (e) => {
const activeTag = document.activeElement.tagName;
if (activeTag === 'INPUT' || activeTag === 'TEXTAREA' || activeTag === 'SELECT') return;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
undo();
return;
}
switch(e.key) {
case '1': document.querySelector('[data-tool="paint"]').click(); break;
case '2': document.querySelector('[data-tool="select"]').click(); break;
case '3': document.querySelector('[data-tool="text"]').click(); break;
case '4': document.querySelector('[data-tool="marker"]').click(); break;
}
});
const markerDialog = document.getElementById('marker-dialog');
document.getElementById('btn-marker-cancel').addEventListener('click', () => markerDialog.close());
document.getElementById('btn-marker-save').addEventListener('click', () => {
saveState();
const icon = document.getElementById('marker-icon').value;
const label = document.getElementById('marker-label').value;
if (editingMarker) {
editingMarker.icon = icon;
editingMarker.label = label;
} else if (pendingMarkerGridPos) {
markers.push({
id: markerIdCounter++,
x: pendingMarkerGridPos.x, y: pendingMarkerGridPos.y,
icon: icon, label: label, color: currentColor
});
}
markerDialog.close();
draw();
});
document.getElementById('btn-marker-delete').addEventListener('click', () => {
if (editingMarker) {
saveState();
const index = markers.indexOf(editingMarker);
if (index > -1) markers.splice(index, 1);
markerDialog.close();
draw();
}
});
document.querySelectorAll('.color-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.color-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
currentColor = e.target.dataset.color;
});
});
document.querySelectorAll('button.tool').forEach(btn => {
btn.addEventListener('click', (e) => {
currentTool = e.target.dataset.tool;
document.querySelectorAll('button.tool').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
if (currentTool !== 'select') {
selection = null;
document.getElementById('selection-actions').style.display = 'none';
draw();
}
});
});
document.getElementById('btn-zoom-in').addEventListener('click', () => handleZoom(1.5));
document.getElementById('btn-zoom-out').addEventListener('click', () => handleZoom(1 / 1.5));
document.getElementById('btn-zoom-reset').addEventListener('click', () => {
const oldZoom = zoom;
zoom = 1;
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
camera.x = centerX - (centerX - camera.x) * (1 / oldZoom);
camera.y = centerY - (centerY - camera.y) * (1 / oldZoom);
draw();
});
document.getElementById('btn-fill').addEventListener('click', () => {
if (!selection) return;
saveState();
const minX = Math.min(selection.startX, selection.endX);
const maxX = Math.max(selection.startX, selection.endX);
const minY = Math.min(selection.startY, selection.endY);
const maxY = Math.max(selection.startY, selection.endY);
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) cells.set(`${x},${y}`, currentColor);
}
draw();
});
document.getElementById('btn-clear').addEventListener('click', () => {
if (!selection) return;
saveState();
const minX = Math.min(selection.startX, selection.endX);
const maxX = Math.max(selection.startX, selection.endX);
const minY = Math.min(selection.startY, selection.endY);
const maxY = Math.max(selection.startY, selection.endY);
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) cells.delete(`${x},${y}`);
}
draw();
});
document.getElementById('btn-export').addEventListener('click', () => {
const offCanvas = document.createElement('canvas');
const offCtx = offCanvas.getContext('2d');
const exportZoom = 1;
const exportCellSize = BASE_CELL_SIZE;
if (selection) {
const minX = Math.min(selection.startX, selection.endX);
const maxX = Math.max(selection.startX, selection.endX);
const minY = Math.min(selection.startY, selection.endY);
const maxY = Math.max(selection.startY, selection.endY);
offCanvas.width = (maxX - minX + 1) * exportCellSize;
offCanvas.height = (maxY - minY + 1) * exportCellSize;
const exportCamera = { x: -(minX * exportCellSize), y: -(minY * exportCellSize) };
const tempSelection = selection;
selection = null;
draw(offCtx, exportCamera, exportZoom);
selection = tempSelection;
} else {
offCanvas.width = 2560;
offCanvas.height = 1440;
const screenCenterX = canvas.width / 2;
const screenCenterY = canvas.height / 2;
const centerGridX = (screenCenterX - camera.x) / getCellSize();
const centerGridY = (screenCenterY - camera.y) / getCellSize();
const exportCamera = {
x: (offCanvas.width / 2) - (centerGridX * exportCellSize),
y: (offCanvas.height / 2) - (centerGridY * exportCellSize)
};
draw(offCtx, exportCamera, exportZoom);
}
const link = document.createElement('a');
link.download = selection ? 'grid-selection.png' : 'grid-2k.png';
link.href = offCanvas.toDataURL('image/png');
link.click();
});
resize();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment