Skip to content

Instantly share code, notes, and snippets.

@adammyhre
Created April 5, 2026 10:38
Show Gist options
  • Select an option

  • Save adammyhre/be689c65fedd2aa2f84495b72d7c6021 to your computer and use it in GitHub Desktop.

Select an option

Save adammyhre/be689c65fedd2aa2f84495b72d7c6021 to your computer and use it in GitHub Desktop.
A simple Unity survival building system using modular pieces and socket-based snapping for clean placement.
using UnityEngine;
public enum SimpleBuildPieceType { Floor, Wall, Door }
public class SimpleBuildPart : MonoBehaviour {
[SerializeField] SimpleBuildPieceType pieceType = SimpleBuildPieceType.Floor; // identity for occupancy and placer logic
public SimpleBuildPieceType Type => pieceType;
public void SetPieceType(SimpleBuildPieceType value) => pieceType = value;
}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public class SimpleBuildPlacer : MonoBehaviour {
#region Fields
[Header("Camera")] [SerializeField] Camera mainCamera;
[Header("Prefabs & Tool")] [SerializeField]
SimpleBuildPieceType placementPieceType = SimpleBuildPieceType.Floor;
[SerializeField] GameObject floorTilePrefab;
[SerializeField] GameObject wallTilePrefab;
[SerializeField] GameObject doorTilePrefab;
[Header("Ground Placement")] [SerializeField]
LayerMask placementMask; // solid ground / meshes (exclude Socket so rays pass through to hit ground)
[SerializeField] float gridSize = 2.5f;
[SerializeField] bool snapFloorToGrid;
[Header("Socket Raycasts")] [SerializeField]
bool useSocketSnapping = true; // prefer socket triggers before free placement
[SerializeField] LayerMask socketRaycastMask;
[SerializeField] float socketRaycastDistance = 500f;
[SerializeField] float groundRaycastDistance = 500f;
[Header("Socket Occupancy")] [SerializeField]
bool skipSocketIfOccupied = true; // ignore sockets that already have an Occupant reference
[Header("Preview")] [SerializeField] Material previewMaterialValid;
[SerializeField] Material previewMaterialGhost;
[SerializeField] float previewLiftY = 0.02f;
GameObject previewInstance;
RaycastHit[] hitsBuffer;
readonly List<SimpleEdgeSocket> availableSockets = new(8); // same GO can have Floor + Wall socket components
int placementPieceTypeLastFrame; // detects tool change to rebuild preview
#endregion
GameObject ActivePrefab => placementPieceType switch {
SimpleBuildPieceType.Wall => wallTilePrefab,
SimpleBuildPieceType.Door => doorTilePrefab,
SimpleBuildPieceType.Floor => floorTilePrefab
};
void Awake() {
if (mainCamera == null) mainCamera = Camera.main;
if (socketRaycastMask.value == 0) socketRaycastMask = LayerMask.GetMask("Socket");
if (placementMask.value == 0) {
var sl = LayerMask.NameToLayer("Socket");
placementMask = sl >= 0 ? ~(1 << sl) : ~0;
}
hitsBuffer = new RaycastHit[16];
placementPieceTypeLastFrame = (int)placementPieceType;
}
void OnDisable() {
if (previewInstance != null) Destroy(previewInstance);
}
void UpdatePreview(Pose pose, bool valid) {
if (previewInstance == null) {
var p = ActivePrefab;
if (p == null) return;
previewInstance = Instantiate(p);
previewInstance.hideFlags = HideFlags.DontSave;
foreach (var c in previewInstance.GetComponentsInChildren<Collider>()) c.enabled = false;
}
var pos = pose.position;
pos.y += previewLiftY;
previewInstance.transform.SetPositionAndRotation(pos, pose.rotation);
var mat = valid ? previewMaterialValid : previewMaterialGhost;
if (mat == null) return;
foreach (var r in previewInstance.GetComponentsInChildren<Renderer>()) r.sharedMaterial = mat;
}
void Update() {
if (mainCamera == null || Mouse.current == null) return;
var mouse = Mouse.current;
if ((int)placementPieceType != placementPieceTypeLastFrame) {
if (previewInstance != null) Destroy(previewInstance);
previewInstance = null;
placementPieceTypeLastFrame = (int)placementPieceType;
}
if (!TryGetPreviewPose(mouse.position.ReadValue(), out var pose, out var placementValid, out var socketSnap)) {
if (previewInstance != null) previewInstance.SetActive(false);
return;
}
UpdatePreview(pose, placementValid);
if (mouse.leftButton.wasPressedThisFrame && placementValid) Place(ActivePrefab, pose, socketSnap);
}
bool TryGetPreviewPose(Vector2 screenPos, out Pose pose, out bool placementValid, out SimpleEdgeSocket socketFromSnap) {
pose = default;
placementValid = false;
socketFromSnap = null;
Ray ray = mainCamera.ScreenPointToRay(screenPos);
// 1. Socket snapping (early out)
if (useSocketSnapping && socketRaycastMask.value != 0 && TrySnapSocket(ray, out pose, out socketFromSnap)) {
placementValid = true;
return true;
}
// 2. Try raycast
if (Physics.Raycast(ray, out var hit, groundRaycastDistance, placementMask, QueryTriggerInteraction.Ignore)) {
var pos = hit.point;
if (placementPieceType == SimpleBuildPieceType.Floor) {
if (snapFloorToGrid) SnapGrid(ref pos);
placementValid = true;
}
pose = new Pose(pos, Quaternion.identity);
return true;
}
// 3. Fallback to horizontal plane
if (TryHorizontalPlane(ray, out var point)) {
if (placementPieceType == SimpleBuildPieceType.Floor) {
if (snapFloorToGrid) SnapGrid(ref point);
placementValid = true;
}
pose = new Pose(point, Quaternion.identity);
return true;
}
return false;
}
void Place(GameObject prefab, Pose pose, SimpleEdgeSocket socketSnap) {
if (prefab == null) return;
var go = Instantiate(prefab, pose.position, pose.rotation);
if (socketSnap == null) return;
var part = go.GetComponent<SimpleBuildPart>();
if (part != null) socketSnap.SetOccupant(part);
}
void SnapGrid(ref Vector3 p) {
if (gridSize <= 0f) return;
p.x = Mathf.Round(p.x / gridSize) * gridSize;
p.z = Mathf.Round(p.z / gridSize) * gridSize;
}
bool TrySnapSocket(Ray ray, out Pose bestPose, out SimpleEdgeSocket socket) {
bestPose = default;
socket = null;
var n = Physics.RaycastNonAlloc(ray, hitsBuffer, socketRaycastDistance, socketRaycastMask, QueryTriggerInteraction.Collide);
if (n <= 0) return false;
var best = float.MaxValue;
var found = false;
for (var i = 0; i < n; i++) {
var h = hitsBuffer[i];
if (h.collider == null) continue;
availableSockets.Clear();
h.collider.GetComponents(availableSockets);
SimpleEdgeSocket edge = null;
for (var j = 0; j < availableSockets.Count; j++) {
var s = availableSockets[j];
if (s.CanAcceptPart(placementPieceType)) {
edge = s;
break;
}
}
if (edge == null) continue;
if (skipSocketIfOccupied && edge.IsOccupied) continue;
var candidate = edge.GetSnapPose();
if (h.distance < best) {
best = h.distance;
bestPose = candidate;
socket = edge;
found = true;
}
}
return found;
}
static bool TryHorizontalPlane(Ray ray, out Vector3 hit) {
hit = default;
if (Mathf.Abs(ray.direction.y) < 1e-5f) return false;
var t = -ray.origin.y / ray.direction.y;
if (t < 0f) return false;
hit = ray.origin + ray.direction * t;
hit.y = 0f;
return true;
}
}
using UnityEngine;
using UnityUtils;
public abstract class SimpleEdgeSocket : MonoBehaviour {
[SerializeField] protected Vector3 snapLocalPosition;
[SerializeField] protected Vector3 snapLocalRotationEuler;
[SerializeField] protected float triggerRadius = 0.3f;
[SerializeField] protected string socketLayerName = "Socket";
SimpleBuildPart occupant; // piece placed on this socket via SimpleBuildPlacer (one-way: socket owns the reference)
public SimpleBuildPart Occupant => occupant;
public bool IsOccupied => occupant != null;
public virtual Pose GetSnapPose() => new(GetSnapWorldPosition(), GetSnapWorldRotation());
protected Vector3 GetSnapWorldPosition() => transform.position + transform.TransformDirection(snapLocalPosition);
protected Quaternion GetSnapWorldRotation() => transform.rotation * Quaternion.Euler(snapLocalRotationEuler);
public void SetOccupant(SimpleBuildPart part) {
if (part != null && !CanAcceptPart(part.Type)) return;
if (occupant == part) return;
occupant = part;
}
public virtual bool CanAcceptPart(SimpleBuildPieceType pieceType) => false;
void Awake() => ApplyLayerAndCollider();
void Reset() => ApplyLayerAndCollider();
void OnValidate() => ApplyLayerAndCollider();
protected void ApplyLayerAndCollider() {
var idx = LayerMask.NameToLayer(socketLayerName);
if (idx >= 0) gameObject.layer = idx;
var sc = gameObject.GetOrAdd<SphereCollider>();
sc.isTrigger = true;
sc.radius = triggerRadius;
sc.center = Vector3.zero;
}
}
using UnityEngine;
public class SimpleFloorSocket : SimpleEdgeSocket {
[SerializeField] bool acceptsFloorPieces = true;
public override bool CanAcceptPart(SimpleBuildPieceType pieceType) =>
pieceType == SimpleBuildPieceType.Floor && acceptsFloorPieces;
}
using UnityEngine;
public class SimpleWallSocket : SimpleEdgeSocket {
[SerializeField] bool acceptsWallPieces = true;
[SerializeField] bool acceptsDoorPieces = true;
public override bool CanAcceptPart(SimpleBuildPieceType pieceType) => pieceType switch {
SimpleBuildPieceType.Wall => acceptsWallPieces,
SimpleBuildPieceType.Door => acceptsDoorPieces,
_ => false
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment